Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
+125 -90
View File
@@ -1,65 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BarChart3, Clock3, Sparkles, Tags, Users, Vote } from '@lucide/vue'
import { BarChart3, CheckCircle2 } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminAnalyticsManager } from '../../components/admin/useAdminAnalyticsManager'
import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
const maxVotes = computed(() => Math.max(...store.admin.topCategories.map((category) => category.votes), 1))
const categoryHealth = computed(() =>
seasonDetail.value.categories
.map((category) => ({
name: category.name,
groupName: category.groupName,
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
reviews: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
}))
.sort((a, b) => b.candidates - a.candidates || b.reviews - a.reviews),
)
const metricCards = computed(() => [
{ label: 'Nominierungen', value: totalNominations.value, note: 'gesamt im Jahr', icon: Sparkles },
{ label: 'Stimmen', value: totalVotes.value, note: 'alle Votes', icon: Vote },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'in allen Kategorien', icon: Users },
{ label: 'Reviews offen', value: seasonDetail.value.pendingNominations.length, note: 'Backlog', icon: Clock3 },
])
const insights = computed(() => {
const categoriesWithoutCandidates = categoryHealth.value.filter((category) => category.candidates === 0).length
const busiestReviewCategory = [...categoryHealth.value].sort((a, b) => b.reviews - a.reviews)[0]
const votesPerCandidate = seasonDetail.value.candidates.length === 0 ? 0 : Math.round(totalVotes.value / seasonDetail.value.candidates.length)
return [
{
label: 'Votes pro Kandidat',
value: votesPerCandidate,
note: 'Hilft einzuschätzen, ob die Kandidatenbasis breit genug ist.',
},
{
label: 'Leere Kategorien',
value: categoriesWithoutCandidates,
note: categoriesWithoutCandidates === 0 ? 'Alle Kategorien sind besetzt.' : 'Diese Kategorien brauchen Kandidatenpflege.',
},
{
label: 'Review-Hotspot',
value: busiestReviewCategory?.reviews ?? 0,
note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine Review-Daten vorhanden.',
},
]
})
const {
categoryHealth,
metricCards,
readinessCards,
insightCards,
attentionItems,
topCategories,
maxVotes,
winnerCoveragePct,
} = useAdminAnalyticsManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Analytics"
title="Zahlen, die Entscheidungen helfen"
description="Verdichte Voting-, Kategorie- und Review-Daten in eine Admin-Ansicht, damit das Team sofort erkennt, wo Reichweite, Lücken oder Backlog entstehen."
description="Jahresmetriken, Readiness und Kategoriegesundheit auf einen Blick."
:icon="BarChart3"
/>
@@ -69,63 +33,134 @@ const insights = computed(() => {
<Card v-for="metric in metricCards" :key="metric.label" class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ metric.label }}</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ metric.value.toLocaleString('de-DE') }}</strong>
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{{ metric.label }}</p>
<strong class="mt-2 block text-3xl text-slate-950">{{ metric.value.toLocaleString('de-DE') }}</strong>
<p class="mt-1 text-sm text-slate-500">{{ metric.note }}</p>
</div>
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl border" :class="metric.tone">
<component :is="metric.icon" class="h-5 w-5" />
</div>
</span>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
<section class="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Vote Performance</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top-Kategorien</h2>
<div class="mt-6 space-y-4">
<div v-for="category in store.admin.topCategories" :key="category.category" class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<div class="flex items-center justify-between gap-4">
<p class="font-semibold text-slate-900">{{ category.category }}</p>
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
</div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-[#f3ecff]">
<div class="h-full rounded-full bg-[linear-gradient(90deg,#a78bfa,#f5a9d6,#f8d7a4)]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
</div>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Finale Vollständigkeit</h2>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50 px-4 py-3 text-sm font-bold text-violet-800">
{{ winnerCoveragePct }}% Gewinner
</div>
</div>
<div class="mt-5 grid gap-3">
<RouterLink
v-for="item in readinessCards"
:key="item.label"
:to="item.to"
class="flex items-start gap-4 rounded-[22px] border border-violet-100 bg-white p-4 transition hover:bg-violet-50/60"
>
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="item.icon" class="h-5 w-5" />
</span>
<span class="min-w-0">
<span class="block text-sm font-semibold text-slate-500">{{ item.label }}</span>
<strong class="mt-1 block text-2xl leading-tight text-slate-950">{{ item.value }}</strong>
<span class="mt-1 block text-sm leading-5 text-slate-500">{{ item.note }}</span>
</span>
</RouterLink>
</div>
</Card>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorie Health</p>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Abdeckung</h2>
</div>
<Tags class="h-6 w-6 text-violet-500" />
</div>
<Card class="p-6">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aufmerksamkeit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was Admins zuerst prüfen sollten</h2>
</div>
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
<div v-for="category in categoryHealth" :key="category.name" class="grid grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4">
<div class="min-w-0">
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.reviews }} offene Reviews</p>
</div>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">
{{ category.candidates }} Kandidaten
</span>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<RouterLink
v-for="item in attentionItems"
:key="item.key"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:translate-y-[-1px]"
:class="item.tone"
>
<strong class="block text-3xl leading-none">{{ item.value }}</strong>
<span class="mt-3 block text-sm font-bold">{{ item.label }}</span>
<span class="mt-1 block text-xs leading-5 opacity-80">{{ item.note }}</span>
</RouterLink>
</div>
<div class="mt-6 grid gap-3 md:grid-cols-3">
<div v-for="insight in insightCards" :key="insight.label" class="rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
<component :is="insight.icon" class="h-5 w-5 text-violet-600" />
<p class="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{{ insight.label }}</p>
<strong class="mt-1 block text-2xl text-slate-950">{{ insight.value }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ insight.note }}</p>
</div>
</div>
</Card>
</section>
<section class="grid gap-4 lg:grid-cols-3">
<Card v-for="insight in insights" :key="insight.label" class="p-5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ insight.label }}</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ insight.value.toLocaleString('de-DE') }}</strong>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ insight.note }}</p>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Kandidaten, Reviews und Gewinnerstatus</h2>
</div>
<div class="max-h-[520px] overflow-y-auto p-4">
<div class="grid gap-3">
<RouterLink
v-for="category in categoryHealth"
:key="category.id"
to="/admin/categories"
class="grid gap-3 rounded-[22px] border border-violet-100 bg-white p-4 transition hover:bg-violet-50/60 md:grid-cols-[minmax(0,1fr)_110px_110px_130px]"
>
<span class="min-w-0">
<strong class="block truncate text-slate-950">{{ category.name }}</strong>
<span class="mt-1 block text-sm text-slate-500">{{ category.groupName || 'Ohne Gruppe' }}</span>
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.candidates }}</strong>
Kandidaten
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
Stimmen
</span>
<span class="inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
{{ category.status }}
</span>
</RouterLink>
</div>
</div>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Top Kategorien</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Stimmenverteilung</h2>
<div class="mt-5 space-y-4">
<div v-for="category in topCategories.slice(0, 8)" :key="category.category">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="truncate font-semibold text-slate-800">{{ category.category }}</span>
<span class="shrink-0 text-slate-500">{{ category.votes.toLocaleString('de-DE') }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-violet-100">
<div class="h-full rounded-full bg-violet-600" :style="{ width: `${Math.max(4, Math.round((category.votes / maxVotes) * 100))}%` }"></div>
</div>
</div>
<div v-if="topCategories.length === 0" class="rounded-[22px] border border-dashed border-violet-100 p-5 text-sm text-slate-500">
Noch keine Stimmenverteilung vorhanden.
</div>
</div>
<div class="mt-6 flex items-center gap-2 rounded-[22px] border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-800">
<CheckCircle2 class="h-5 w-5 shrink-0" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
</div>
</Card>
</section>
</div>
+86 -308
View File
@@ -1,168 +1,54 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import Select from 'primevue/select'
import { ChevronLeft, ChevronRight, Layers3, Pencil, Search, Trash2, TriangleAlert, UserPlus, Users, X } from '@lucide/vue'
import { Layers3, UserPlus, Users } from '@lucide/vue'
import AdminCandidateDeleteModal from '../../components/admin/AdminCandidateDeleteModal.vue'
import AdminCandidateEditorModal from '../../components/admin/AdminCandidateEditorModal.vue'
import AdminCandidatesFiltersBar from '../../components/admin/AdminCandidatesFiltersBar.vue'
import AdminCandidatesTable from '../../components/admin/AdminCandidatesTable.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import { useAdminCandidateManager } from '../../components/admin/useAdminCandidateManager'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem } from '../../types/awards'
const store = useAwardsStore()
const saving = ref(false)
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
/* ---------- Filter + Suche + Pagination ---------- */
const search = ref('')
const categoryFilter = ref<number | null>(null)
const page = ref(1)
const pageSize = 10
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
)
const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((c) => [c.id, `${c.groupName} · ${c.name}`])),
)
const duplicateCandidateKeys = computed(() => {
const counts = new Map<string, number>()
for (const candidate of seasonDetail.value.candidates) {
const categoryKey = `${candidate.categoryId}`
const nameKey = `${categoryKey}:name:${candidate.displayName.trim().toLowerCase()}`
const slugKey = `${categoryKey}:slug:${candidate.channelSlug.trim().toLowerCase()}`
counts.set(nameKey, (counts.get(nameKey) ?? 0) + 1)
if (candidate.channelSlug.trim()) counts.set(slugKey, (counts.get(slugKey) ?? 0) + 1)
}
return counts
})
const duplicateCandidateCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) =>
(duplicateCandidateKeys.value.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1 ||
(duplicateCandidateKeys.value.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1,
).length,
)
const filteredCandidates = computed(() => {
const query = search.value.trim().toLowerCase()
let list = seasonDetail.value.candidates
if (categoryFilter.value) list = list.filter((c) => c.categoryId === categoryFilter.value)
if (query) {
list = list.filter((c) =>
[c.displayName, c.channelSlug, c.platform, categoryLabelMap.value[c.categoryId] ?? '']
.join(' ')
.toLowerCase()
.includes(query),
)
}
return list
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
const pagedCandidates = computed(() => {
const start = (page.value - 1) * pageSize
return filteredCandidates.value.slice(start, start + pageSize)
})
const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
page.value = 1
})
watch(totalPages, (max) => {
if (page.value > max) page.value = max
})
function clearFilters() {
search.value = ''
categoryFilter.value = null
}
/* ---------- Modal: anlegen / bearbeiten ---------- */
const modalOpen = ref(false)
const editingId = ref<number | 'new' | null>(null)
const form = reactive({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
const canSave = computed(() =>
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim()),
)
function openCreate() {
adminMessage.value = ''
adminError.value = ''
editingId.value = 'new'
form.categoryId = categoryFilter.value ?? seasonDetail.value.categories[0]?.id ?? 0
form.displayName = ''
form.channelSlug = ''
form.platform = 'Twitch'
modalOpen.value = true
}
function openEdit(candidate: AdminCandidateItem) {
adminMessage.value = ''
adminError.value = ''
editingId.value = candidate.id
form.categoryId = candidate.categoryId
form.displayName = candidate.displayName
form.channelSlug = candidate.channelSlug
form.platform = candidate.platform
modalOpen.value = true
}
async function saveModal() {
if (!canSave.value || !selectedSeasonId.value) return
saving.value = true
adminError.value = ''
try {
if (editingId.value === 'new') {
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde angelegt.`
} else if (typeof editingId.value === 'number') {
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde gespeichert.`
}
modalOpen.value = false
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
/* ---------- Löschen mit Bestätigung ---------- */
const candidateToDelete = ref<AdminCandidateItem | null>(null)
async function confirmDelete() {
if (!candidateToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `${candidateToDelete.value.displayName}" wurde gelöscht.`
candidateToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
const {
seasonDetail,
saving,
deleting,
adminMessage,
adminError,
search,
categoryFilter,
page,
categoryOptions,
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
duplicateCandidateCount,
filteredCandidates,
pagedCandidates,
totalPages,
rangeStart,
rangeEnd,
modalOpen,
form,
modalTitle,
canSave,
candidatePlatformOptions,
selectedPlatformValue,
candidateToDelete,
clearFilters,
openCreate,
openEdit,
handlePlatformSelection,
saveModal,
confirmDelete,
} = useAdminCandidateManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Kandidaten"
title="Kandidaten verwalten"
description="Suchen, filtern, anlegen, bearbeiten und löschen auch bei vielen Nominierten bleibt die Liste übersichtlich."
:icon="Users"
/>
@@ -199,167 +85,59 @@ async function confirmDelete() {
</section>
<Card class="overflow-hidden">
<!-- Toolbar -->
<div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
<label class="relative block flex-1">
<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="search"
type="text"
class="h-11 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="Name, Handle oder Plattform suchen …"
/>
</label>
<Select
v-model="categoryFilter"
:options="categoryFilterOptions"
option-label="label"
option-value="value"
class="w-full lg:w-72"
/>
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="clearFilters">
<X class="h-4 w-4" /> Filter
</Button>
<Button class="gap-2" @click="openCreate">
<UserPlus class="h-4 w-4" /> Kandidat anlegen
</Button>
</div>
<AdminCandidatesFiltersBar
:search="search"
:category-filter="categoryFilter"
:category-filter-options="categoryFilterOptions"
@update:search="search = $event"
@update:category-filter="categoryFilter = $event"
@clear-filters="clearFilters"
@open-create="openCreate"
/>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<!-- Tabellenkopf (Desktop) -->
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<span>Kandidat</span>
<span>Kategorie</span>
<span>Plattform</span>
<span class="text-right">Aktionen</span>
</div>
<!-- Zeilen -->
<div class="divide-y divide-violet-50">
<div
v-for="candidate in pagedCandidates"
:key="candidate.id"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
>
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ candidate.displayName.charAt(0) }}
</span>
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
<p
v-if="(duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1 || (duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1"
class="mt-1 text-xs font-semibold text-amber-700"
>
Mögliches Duplikat in dieser Kategorie
</p>
</div>
</div>
<div class="min-w-0">
<span class="inline-block max-w-full truncate rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-xs font-semibold text-violet-700">
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
</span>
</div>
<div>
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
</div>
<div class="flex gap-2 lg:justify-end">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
title="Bearbeiten"
@click="openEdit(candidate)"
>
<Pencil class="h-4 w-4" />
</button>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Löschen"
@click="candidateToDelete = candidate"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
<div v-if="filteredCandidates.length === 0" class="px-6 py-12 text-center">
<p class="text-sm text-slate-500">
{{ seasonDetail.candidates.length === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p>
<Button class="mt-4 gap-2" @click="openCreate"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
</div>
</div>
<!-- Pagination -->
<div v-if="filteredCandidates.length > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
<span><strong class="text-violet-800">{{ rangeStart }}{{ rangeEnd }}</strong> von {{ filteredCandidates.length }}</span>
<div class="flex items-center gap-2">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page <= 1"
@click="page--"
>
<ChevronLeft class="h-4 w-4" />
</button>
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page >= totalPages"
@click="page++"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
<AdminCandidatesTable
:paged-candidates="pagedCandidates"
:total-count="seasonDetail.candidates.length"
:filtered-count="filteredCandidates.length"
:page="page"
:total-pages="totalPages"
:range-start="rangeStart"
:range-end="rangeEnd"
:category-label-map="categoryLabelMap"
:duplicate-candidate-keys="duplicateCandidateKeys"
@edit="openEdit"
@delete="candidateToDelete = $event"
@update:page="page = $event"
@open-create="openCreate"
/>
</Card>
<!-- Modal: anlegen / bearbeiten -->
<Modal :open="modalOpen" :title="modalTitle" subtitle="Anzeigename und Handle sind Pflicht." @close="modalOpen = false">
<div class="space-y-4">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select v-model="form.categoryId" :options="categoryOptions" option-label="label" option-value="value" class="w-full" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="form.displayName" type="text" class="h-11 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="z. B. Jayuhime" />
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input v-model="form.channelSlug" type="text" class="h-11 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="@channel" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<input v-model="form.platform" type="text" class="h-11 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="Twitch, YouTube …" />
</label>
</div>
</div>
<template #footer>
<Button variant="ghost" @click="modalOpen = false">Abbrechen</Button>
<Button :disabled="saving || !canSave" @click="saveModal">{{ saving ? 'Speichert ' : 'Speichern' }}</Button>
</template>
</Modal>
<AdminCandidateEditorModal
:open="modalOpen"
:title="modalTitle"
:form="form"
:category-options="categoryOptions"
:candidate-platform-options="candidatePlatformOptions"
:selected-platform-value="selectedPlatformValue"
:can-save="canSave"
:saving="saving"
@close="modalOpen = false"
@save="saveModal"
@update:category-id="form.categoryId = $event"
@update:display-name="form.displayName = $event"
@update:channel-slug="form.channelSlug = $event"
@update:platform="form.platform = $event"
@platform-selection="handlePlatformSelection"
/>
<!-- Modal: löschen bestätigen -->
<Modal :open="!!candidateToDelete" title="Kandidat löschen?" @close="candidateToDelete = null">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ candidateToDelete?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="candidateToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</template>
</Modal>
<AdminCandidateDeleteModal
:candidate="candidateToDelete"
:deleting="deleting"
@close="candidateToDelete = null"
@confirm="confirmDelete"
/>
</div>
</template>
+24 -167
View File
@@ -1,183 +1,40 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { Layers3, PlusCircle, Search, Tags, Trash2, TriangleAlert } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminCategoryManager } from '../../components/admin/useAdminCategoryManager'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCategoryItem } from '../../types/awards'
const store = useAwardsStore()
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const seasonDetail = computed(() => store.adminSeasonDetail)
const query = ref('')
const statusFilter = ref<'all' | 'empty' | 'reviews' | 'thin'>('all')
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()
return categoriesWithState.value.filter((category) => {
const matchesStatus =
statusFilter.value === 'all' ||
(statusFilter.value === 'empty' && category.candidates === 0) ||
(statusFilter.value === 'reviews' && category.pending > 0) ||
(statusFilter.value === 'thin' && category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser))
const matchesSearch = !search || [category.groupName, category.name, category.slug, category.description]
.join(' ')
.toLowerCase()
.includes(search)
return matchesStatus && matchesSearch
})
})
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 },
])
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length },
{ key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length },
{ key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length },
{ key: 'thin' as const, label: 'Dünn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).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
}
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function fillNewSlug() {
newCategoryForm.slug = slugify(newCategoryForm.name)
}
const categoryToDelete = ref<AdminCategoryItem | null>(null)
const deleting = ref(false)
async function confirmDeleteCategory() {
if (!categoryToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCategory(categoryToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `Kategorie „${categoryToDelete.value.name}" wurde gelöscht.`
categoryToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
const {
selectedSeasonId,
query,
statusFilter,
selectedCategoryId,
saving,
adminMessage,
adminError,
editForms,
newCategoryForm,
filteredCategories,
selectedCategory,
categoryStats,
statusFilters,
categoryToDelete,
deleting,
saveCategory,
createCategory,
fillNewSlug,
confirmDeleteCategory,
} = useAdminCategoryManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Kategorien"
title="Award-Struktur pflegen"
description="Eine kompakte Arbeitsansicht für viele Kategorien: links filtern und auswählen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten."
:icon="Tags"
/>
@@ -243,7 +100,7 @@ async function confirmDeleteCategory() {
<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>
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ 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">
@@ -301,7 +158,7 @@ async function confirmDeleteCategory() {
</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>
<h2 class="mt-1 text-lg font-bold text-slate-900">Kategorie anlegen</h2>
</div>
</div>
+68 -105
View File
@@ -1,117 +1,51 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ExternalLink, Film, Layers3, PlayCircle, Search, Trash2, TriangleAlert, Users } from '@lucide/vue'
import { CheckCircle2, ExternalLink, Film, Search, Trash2, TriangleAlert, Undo2, Users, XCircle } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminClipManager } from '../../components/admin/useAdminClipManager'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminClipSubmissionItem } from '../../types/awards'
const store = useAwardsStore()
const query = ref('')
const statusFilter = ref<'all' | 'pending' | 'reviewed'>('all')
const platformFilter = ref<'all' | string>('all')
const categoryFilter = ref('all')
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
const categories = computed(() => seasonDetail.value.categories ?? [])
const categoryName = computed(() =>
Object.fromEntries(categories.value.map((category) => [category.id, category.name])),
)
const clips = computed(() => {
const search = query.value.trim().toLowerCase()
return submissions.value.filter((clip) =>
(statusFilter.value === 'all' || clip.status === statusFilter.value || (statusFilter.value === 'reviewed' && clip.status !== 'pending')) &&
(platformFilter.value === 'all' || clip.platform === platformFilter.value) &&
(categoryFilter.value === 'all' || String(clip.categoryId) === categoryFilter.value) &&
(!search || [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search)),
)
})
const duplicateUrls = computed(() => {
const counts = new Map<string, number>()
for (const clip of submissions.value) {
const key = clip.clipUrl.trim().toLowerCase()
if (!key) continue
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
})
const stats = computed(() => [
{ label: 'Einreichungen', value: submissions.value.length, icon: Film },
{ label: 'Offen', value: submissions.value.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ label: 'Duplikate', value: [...duplicateUrls.value.values()].filter((count) => count > 1).length, icon: Layers3 },
])
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: submissions.value.length },
{ key: 'pending' as const, label: 'Offen', count: submissions.value.filter((clip) => clip.status === 'pending').length },
{ key: 'reviewed' as const, label: 'Geprüft', count: submissions.value.filter((clip) => clip.status !== 'pending').length },
])
const platformFilters = computed(() => [
{ key: 'all', label: 'Alle Plattformen', count: submissions.value.length },
...[...new Set(submissions.value.map((clip) => clip.platform).filter(Boolean))]
.sort()
.map((platform) => ({
key: platform,
label: platform,
count: submissions.value.filter((clip) => clip.platform === platform).length,
})),
])
const categoryFilters = computed(() => [
{ id: 'all' as const, label: 'Alle Kategorien', count: submissions.value.length },
...categories.value
.filter((category) => submissions.value.some((clip) => clip.categoryId === category.id))
.map((category) => ({
id: String(category.id),
label: category.name,
count: submissions.value.filter((clip) => clip.categoryId === category.id).length,
})),
])
function platformClass(platform: string) {
if (platform === 'Twitch') return 'border-violet-200 bg-violet-50 text-violet-700'
if (platform === 'YouTube') return 'border-rose-200 bg-rose-50 text-rose-600'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
async function confirmDelete() {
if (!clipToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminClip(clipToDelete.value.id, selectedSeasonId.value)
adminMessage.value = 'Clip-Einreichung wurde entfernt.'
clipToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
const {
query,
statusFilter,
platformFilter,
categoryFilter,
deleting,
statusSaving,
adminMessage,
adminError,
clipToDelete,
reviewNotes,
submissions,
categoryName,
clips,
stats,
statusFilters,
platformFilters,
categoryFilters,
platformClass,
statusClass,
statusLabel,
duplicateUrlCount,
creatorClipCount,
updateClipStatus,
confirmDelete,
} = useAdminClipManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Clips"
title="Clip-Einreichungen triagieren"
description="Clips sind eine eigene Award-Arbeitsfläche: nach Kategorie, Plattform und Status filtern, Duplikate erkennen, Links prüfen und Spam oder falsche Einreichungen entfernen."
:icon="Film"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 lg:grid-cols-3">
<section class="grid gap-4 lg: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>
@@ -172,27 +106,56 @@ async function confirmDelete() {
<span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span>
· von {{ clip.submittedByTwitchId }}
</p>
<p v-if="duplicateUrls.get(clip.clipUrl.trim().toLowerCase()) && duplicateUrls.get(clip.clipUrl.trim().toLowerCase())! > 1" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat: diese URL wurde {{ duplicateUrls.get(clip.clipUrl.trim().toLowerCase()) }}x eingereicht.
<p v-if="duplicateUrlCount(clip.clipUrl) > 1" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat: diese URL wurde {{ duplicateUrlCount(clip.clipUrl) }}x eingereicht.
</p>
<p v-if="creatorClipCount(clip) > 1" class="mt-1 text-xs font-semibold text-violet-700">
Sammelpunkt: {{ creatorClipCount(clip) }} Clips für diese Person oder diesen Kandidaten.
</p>
<p v-if="clip.reviewedAt" class="mt-1 text-xs text-slate-500">
Zuletzt geprüft von {{ clip.reviewedByTwitchId || 'Admin' }} · {{ new Date(clip.reviewedAt).toLocaleString('de-DE') }}
</p>
</div>
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', platformClass(clip.platform)]">{{ clip.platform }}</span>
<span class="shrink-0 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">{{ clip.status }}</span>
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', statusClass(clip.status)]">{{ statusLabel(clip.status) }}</span>
<a
:href="clip.clipUrl"
target="_blank"
rel="noopener"
referrerpolicy="no-referrer"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
>
<ExternalLink class="h-3.5 w-3.5" /> Clip öffnen
</a>
<button
class="grid h-9 w-9 shrink-0 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Einreichung entfernen"
@click="clipToDelete = clip"
>
<Trash2 class="h-4 w-4" />
</button>
<div class="w-full lg:w-[320px]">
<textarea
v-model="reviewNotes[clip.id]"
rows="2"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Moderationsnotiz für Team oder spätere Rückfragen"
/>
<div class="mt-3 flex flex-wrap gap-2">
<Button variant="secondary" class="gap-1.5" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'pending')">
<Undo2 class="h-4 w-4" />
Zurück auf offen
</Button>
<Button class="gap-1.5 !bg-emerald-600 hover:!bg-emerald-500" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'approved')">
<CheckCircle2 class="h-4 w-4" />
Freigeben
</Button>
<Button class="gap-1.5 !bg-rose-600 hover:!bg-rose-500" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'rejected')">
<XCircle class="h-4 w-4" />
Ablehnen
</Button>
<button
class="grid h-10 w-10 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Einreichung entfernen"
@click="clipToDelete = clip"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
</div>
<div v-if="clips.length === 0" class="px-5 py-12 text-center">
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { FileText } from '@lucide/vue'
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
import AdminContentLinksSection from '../../components/admin/AdminContentLinksSection.vue'
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
import AdminContentSectionNav from '../../components/admin/AdminContentSectionNav.vue'
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
const sectionLinks = [
{ href: '#content-basics', label: 'Basis & Host', primary: true },
{ href: '#content-links', label: 'Footer & Kontakt' },
{ href: '#content-socials', label: 'Social Links' },
{ href: '#content-faq', label: 'FAQ' },
{ href: '#content-privacy', label: 'Datenschutz' },
]
const {
form,
saving,
saveMessage,
saveError,
privacyPreviewOpen,
iconUploadError,
privacyPreviewBlocks,
privacyUpdatedLabel,
addSocialLink,
removeSocialLink,
addFaqItem,
removeFaqItem,
isUploadedIcon,
selectedSocialIconValue,
handleSocialIconSelection,
hasSocialIconPreview,
socialIconModeLabel,
socialSimpleIconPath,
socialSimpleIconColor,
handleSocialIconUpload,
clearSocialIcon,
saveSiteSettings,
adminSiteSettings,
} = useAdminContentManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Content Hub"
description="Landingpage, Footer, Social Links und Rechtstexte pflegen."
:icon="FileText"
/>
<div class="space-y-3">
<p v-if="saveMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ saveMessage }}</p>
<p v-if="saveError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ saveError }}</p>
</div>
<section class="grid gap-6 xl:grid-cols-[300px_minmax(0,1fr)]">
<aside class="space-y-4 xl:sticky xl:top-32 xl:self-start">
<AdminContentSectionNav :sections="sectionLinks" />
</aside>
<div class="space-y-6">
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentLinksSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentSocialLinksSection
:form="form"
:saving="saving"
:icon-upload-error="iconUploadError"
:add-social-link="addSocialLink"
:remove-social-link="removeSocialLink"
:is-uploaded-icon="isUploadedIcon"
:selected-social-icon-value="selectedSocialIconValue"
:handle-social-icon-selection="handleSocialIconSelection"
:has-social-icon-preview="hasSocialIconPreview"
:social-icon-mode-label="socialIconModeLabel"
:social-simple-icon-path="socialSimpleIconPath"
:social-simple-icon-color="socialSimpleIconColor"
:handle-social-icon-upload="handleSocialIconUpload"
:clear-social-icon="clearSocialIcon"
:save-site-settings="saveSiteSettings"
/>
<AdminContentFaqSection
:form="form"
:saving="saving"
:add-faq-item="addFaqItem"
:remove-faq-item="removeFaqItem"
:save-site-settings="saveSiteSettings"
/>
<AdminContentPrivacySection
:form="form"
:saving="saving"
:updated-by="adminSiteSettings.privacyPolicyUpdatedBy"
:updated-label="privacyUpdatedLabel"
:save-site-settings="saveSiteSettings"
@open-preview="privacyPreviewOpen = true"
/>
</div>
</section>
<AdminContentPrivacyPreviewModal
:open="privacyPreviewOpen"
:blocks="privacyPreviewBlocks"
:updated-label="privacyUpdatedLabel"
@close="privacyPreviewOpen = false"
/>
</div>
</template>
+42 -355
View File
@@ -1,382 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowDownRight, ArrowUpRight, BarChart3, Clock3, LayoutDashboard, ShieldAlert, Sparkles, Tags, Users } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { LayoutDashboard } from '@lucide/vue'
import AdminDashboardActivitySection from '../../components/admin/AdminDashboardActivitySection.vue'
import AdminDashboardChecksSection from '../../components/admin/AdminDashboardChecksSection.vue'
import AdminDashboardHeroSection from '../../components/admin/AdminDashboardHeroSection.vue'
import AdminDashboardPrioritySection from '../../components/admin/AdminDashboardPrioritySection.vue'
import AdminDashboardTopCategoriesSection from '../../components/admin/AdminDashboardTopCategoriesSection.vue'
import AdminDashboardYearTotalsSection from '../../components/admin/AdminDashboardYearTotalsSection.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { useAdminDashboardOverview } from '../../components/admin/useAdminDashboardOverview'
const store = useAwardsStore()
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
const topCategories = computed(() => store.admin.topCategories)
const metricToneMap = {
Nominierungen: {
icon: Sparkles,
trend: 12.4,
sparkline: [42, 48, 53, 51, 59, 64, 71],
context: 'Nominierungsdruck steigt',
},
Stimmen: {
icon: BarChart3,
trend: 8.7,
sparkline: [54, 57, 63, 66, 72, 76, 81],
context: 'Voting-Aktivität stabil positiv',
},
Kategorien: {
icon: Tags,
trend: 0,
sparkline: [62, 62, 62, 63, 63, 63, 63],
context: 'Struktur bleibt konstant',
},
'Reviews offen': {
icon: Clock3,
trend: -6.2,
sparkline: [82, 78, 75, 73, 68, 65, 61],
context: 'Backlog wird kleiner',
},
}
const metricCards = computed(() =>
metrics.value.map((metric) => ({
...metric,
...(metricToneMap[metric.label as keyof typeof metricToneMap] ?? {
icon: BarChart3,
trend: 0,
sparkline: [50, 50, 50, 50, 50, 50, 50],
context: metric.note,
}),
})),
)
const maxCategoryVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
const totalCategoryVotes = computed(() => topCategories.value.reduce((sum, category) => sum + category.votes, 0))
const yearTotals = computed(() => [
{
label: 'Nominierungen gesamt',
value: metrics.value.find((metric) => metric.label === 'Nominierungen')?.value ?? 0,
note: `im Award-Jahr ${store.adminSeasonDetail.year}`,
icon: Sparkles,
},
{
label: 'Stimmen gesamt',
value: getVoteMetricValue(metrics.value),
note: 'alle abgegebenen Votes',
icon: BarChart3,
},
{
label: 'Kandidaten',
value: store.adminSeasonDetail.candidates.length,
note: 'für Voting und Archiv gepflegt',
icon: Users,
},
{
label: 'Kategorien',
value: store.adminSeasonDetail.categories.length,
note: 'aktive Award-Kategorien',
icon: Tags,
},
{
label: 'Offene Reviews',
value: store.adminSeasonDetail.pendingNominations.length,
note: 'brauchen Team-Entscheidung',
icon: Clock3,
},
{
label: 'Risikohinweise',
value: store.admin.riskFlags.length,
note: 'aktuell offen',
icon: ShieldAlert,
},
])
const priorityActions = computed(() => [
{
label: 'Reviews bearbeiten',
value: store.adminSeasonDetail.pendingNominations.length,
to: '/admin/reviews',
hint: 'Freitext-Nominierungen warten auf Entscheidung',
icon: Sparkles,
tone: 'violet',
},
{
label: 'Risiko prüfen',
value: store.admin.riskFlags.length,
to: '/admin/risk',
hint: 'Auffällige Muster brauchen Sichtung',
icon: ShieldAlert,
tone: 'rose',
},
{
label: 'Kategorien pflegen',
value: store.adminSeasonDetail.categories.length,
to: '/admin/categories',
hint: 'Texte, Limits und Reihenfolge aktuell halten',
icon: Tags,
tone: 'amber',
},
{
label: 'Kandidatenbasis',
value: store.adminSeasonDetail.candidates.length,
to: '/admin/candidates',
hint: 'Kandidaten und Plattformen schnell prüfen',
icon: Users,
tone: 'emerald',
},
])
const operationChecks = computed(() => {
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
)
const categoriesWithReviews = store.adminSeasonDetail.categories.filter((category) =>
store.adminSeasonDetail.pendingNominations.some((nomination) => nomination.categoryId === category.id),
)
return [
{
label: 'Kategorien ohne Kandidaten',
value: categoriesWithoutCandidates.length,
to: '/admin/categories',
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt prüfen.',
},
{
label: 'Review-Backlog verteilt',
value: categoriesWithReviews.length,
to: '/admin/nominations',
state: categoriesWithReviews.length <= 1 ? 'ok' : 'warn',
note: categoriesWithReviews.length <= 1 ? 'Backlog ist fokussiert.' : 'Mehrere Kategorien brauchen Sichtung.',
},
{
label: 'Risk Flags offen',
value: store.admin.riskFlags.length,
to: '/admin/risk',
state: store.admin.riskFlags.length === 0 ? 'ok' : 'danger',
note: store.admin.riskFlags.length === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
},
]
})
const {
store,
activities,
topCategories,
metricCards,
openReviewCount,
openRiskCount,
liveSummary,
liveStatusBadge,
maxCategoryVotes,
totalCategoryVotes,
yearTotals,
priorityActions,
operationChecks,
} = useAdminDashboardOverview()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Dashboard"
title="Was braucht gerade Aufmerksamkeit?"
description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebündelt, damit du schneller entscheiden kannst, was als Nächstes drankommt."
:icon="LayoutDashboard"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/60 p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Live-Lage</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-5xl leading-none text-violet-800">Community Momentum</h2>
<p class="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
Voting und Nominierungen ziehen an, während der Review-Backlog sinkt. Gute Lage, aber Risikohinweise bleiben priorisiert.
</p>
</div>
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
+9.8% Gesamtaktivität
</div>
</div>
</div>
<AdminDashboardHeroSection
:metric-cards="metricCards"
:live-summary="liveSummary"
:live-status-badge="liveStatusBadge"
:open-risk-count="openRiskCount"
:open-review-count="openReviewCount"
/>
<div class="grid gap-4 p-5 md:grid-cols-2">
<div
v-for="metric in metricCards"
:key="metric.label"
class="rounded-[24px] border border-violet-100 bg-white/90 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)]"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ metric.label }}</p>
<strong class="mt-3 block text-4xl text-violet-900">{{ metric.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="metric.icon" class="h-5 w-5" />
</div>
</div>
<div class="mt-4 flex items-center justify-between gap-3">
<span
class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold"
:class="metric.trend < 0 ? 'bg-emerald-50 text-emerald-700' : metric.trend > 0 ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600'"
>
<ArrowDownRight v-if="metric.trend < 0" class="h-3.5 w-3.5" />
<ArrowUpRight v-else-if="metric.trend > 0" class="h-3.5 w-3.5" />
{{ metric.trend === 0 ? 'stabil' : `${metric.trend > 0 ? '+' : ''}${metric.trend}%` }}
</span>
<span class="text-xs text-slate-500">{{ metric.context }}</span>
</div>
<div class="mt-5 flex h-16 items-end gap-1.5">
<span
v-for="(value, index) in metric.sparkline"
:key="`${metric.label}-${index}`"
class="flex-1 rounded-t-full bg-gradient-to-t from-[#7c5cff] to-[#c4b5fd]"
:style="{ height: `${value}%` }"
/>
</div>
</div>
</div>
</Card>
<Card class="p-6">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Schnellzugriffe</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was zuerst?</h2>
</div>
<Clock3 class="h-6 w-6 text-amber-500" />
</div>
<div class="mt-5 space-y-3">
<RouterLink
v-for="item in priorityActions"
:key="item.label"
:to="item.to"
class="group block rounded-[22px] border border-violet-100 bg-white/85 p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/70"
>
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-3">
<div
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl"
:class="{
'bg-violet-100 text-violet-700': item.tone === 'violet',
'bg-rose-100 text-rose-700': item.tone === 'rose',
'bg-amber-100 text-amber-700': item.tone === 'amber',
'bg-emerald-100 text-emerald-700': item.tone === 'emerald',
}"
>
<component :is="item.icon" class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ item.label }}</p>
<p class="truncate text-sm text-slate-500">{{ item.hint }}</p>
</div>
</div>
<strong class="rounded-full border border-violet-100 bg-white px-3 py-1 text-violet-800">{{ item.value }}</strong>
</div>
</RouterLink>
</div>
</Card>
<AdminDashboardPrioritySection :priority-actions="priorityActions" />
</section>
<section class="grid gap-4 lg:grid-cols-3">
<RouterLink
v-for="check in operationChecks"
:key="check.label"
:to="check.to"
class="rounded-[24px] border bg-white/85 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)] transition hover:-translate-y-0.5 hover:bg-violet-50/50"
:class="{
'border-emerald-100': check.state === 'ok',
'border-amber-100': check.state === 'warn',
'border-rose-100': check.state === 'danger',
}"
>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{{ check.label }}</p>
<strong class="mt-3 block text-3xl" :class="check.state === 'danger' ? 'text-rose-700' : check.state === 'warn' ? 'text-amber-700' : 'text-emerald-700'">
{{ check.value }}
</strong>
<p class="mt-2 text-sm leading-5 text-slate-500">{{ check.note }}</p>
</div>
</div>
</RouterLink>
</section>
<AdminDashboardChecksSection :operation-checks="operationChecks" />
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Jahreszahlen</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Gesamtmetriken {{ store.adminSeasonDetail.year }}</h2>
</div>
<BarChart3 class="h-6 w-6 text-amber-500" />
</div>
<AdminDashboardYearTotalsSection
:year="store.adminSeasonDetail.year"
:year-totals="yearTotals"
/>
<div class="mt-6 grid gap-3 sm:grid-cols-2">
<div
v-for="item in yearTotals"
:key="item.label"
class="rounded-[22px] border border-violet-100 bg-white/90 p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ item.label }}</p>
<strong class="mt-2 block text-3xl text-violet-900">{{ item.value.toLocaleString('de-DE') }}</strong>
</div>
<div class="grid h-9 w-9 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="item.icon" class="h-4 w-4" />
</div>
</div>
<p class="mt-3 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</div>
</div>
</Card>
<Card class="p-7">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kategorie-Performance</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Stimmen</h2>
</div>
<p class="text-sm text-slate-500">{{ totalCategoryVotes.toLocaleString('de-DE') }} Stimmen in den Top-Kategorien</p>
</div>
<div class="mt-6 space-y-4">
<div
v-for="(category, index) in topCategories"
:key="category.category"
class="rounded-[24px] 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.22em] text-violet-500">#{{ index + 1 }}</p>
<h3 class="mt-1 font-semibold text-slate-800">{{ category.category }}</h3>
</div>
<strong class="text-lg text-violet-800">{{ Number(category.votes).toLocaleString('de-DE') }}</strong>
</div>
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
<div
class="h-3 rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]"
:style="{ width: `${(category.votes / maxCategoryVotes) * 100}%` }"
/>
</div>
</div>
</div>
</Card>
<AdminDashboardTopCategoriesSection
:total-category-votes="totalCategoryVotes"
:max-category-votes="maxCategoryVotes"
:top-categories="topCategories"
/>
</section>
<Card class="p-7">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitäten</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was gerade passiert ist</h2>
</div>
<p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert für den schnellen Blick.</p>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-3">
<div
v-for="activity in activities"
:key="activity.label"
class="rounded-[24px] border border-violet-100 bg-violet-50/60 px-5 py-5"
>
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
<p class="mt-2 text-sm text-slate-500">{{ activity.age }}</p>
</div>
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine aktuellen Audit-Aktivitäten vorhanden.
</p>
</div>
</Card>
<AdminDashboardActivitySection :activities="activities" />
</div>
</template>
+61 -21
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, ref, watch } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import {
AlertTriangle,
@@ -8,30 +8,33 @@ import {
ClipboardList,
Film,
LayoutDashboard,
FileClock,
Settings,
Sparkles,
Tags,
UserCog,
Trophy,
Users,
Vote,
FileText,
} from '@lucide/vue'
import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { getRiskMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth'
const route = useRoute()
const store = useAwardsStore()
const authStore = useAuthStore()
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const pendingClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const adminWorkspaceLoading = ref(false)
const adminWorkspaceLoaded = ref(false)
const navGroups = [
const fullNavGroups = computed(() => [
{
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: 'Readiness und Sperren', icon: Vote, badge: () => `${getVoteMetricValue(store.admin.metrics)}` },
],
},
{
@@ -40,25 +43,40 @@ const navGroups = [
{ 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 prüfen', icon: Film, badge: () => `${store.adminSeasonDetail.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Einreichungen prüfen', icon: Film, badge: () => `${pendingClipCount.value}` },
{ label: 'Landingpage', to: '/admin/content', description: 'FAQ, Footer und Datenschutz', icon: FileText, badge: () => null },
],
},
{
label: 'Kontrolle',
items: [
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Fälle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
{ label: 'Team-Audit', to: '/admin/users-logs', description: 'Admin-Spuren', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` },
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, badge: () => `${openRiskCount.value}` },
{ label: 'Audit-Log', to: '/admin/users-logs', description: 'Admin-Aktionen', icon: FileClock, badge: () => null },
],
},
{
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 },
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, badge: () => null },
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, badge: () => `${store.adminSeasonDetail.results.length}` },
{ label: 'Einstellungen', to: '/admin/settings', description: 'Systemchecks und Status', icon: Settings, badge: () => null },
],
},
]
])
const navGroups = computed(() => {
if (authStore.canManageAdminWorkspace) {
return fullNavGroups.value
}
const allowedRoutes = new Set(['/admin/content', '/admin/settings'])
return fullNavGroups.value
.map((group) => ({
...group,
items: group.items.filter((item) => allowedRoutes.has(item.to)),
}))
.filter((group) => group.items.length > 0)
})
const currentSeason = computed(() => store.adminSeasonDetail)
const seasonSummary = computed(() => [
@@ -71,16 +89,40 @@ function isActive(to: string) {
return route.path === to
}
onMounted(async () => {
if (!authStore.isAdmin) return
await store.initializeAdminWorkspace()
})
async function ensureAdminWorkspace() {
if (!authStore.hydrated || !authStore.canAccessAdmin || adminWorkspaceLoading.value) return
if (adminWorkspaceLoaded.value && store.apiMode === 'api') return
adminWorkspaceLoading.value = true
try {
if (authStore.canManageAdminWorkspace) {
await store.initializeAdminWorkspace()
} else {
await store.loadAdminContentWorkspace()
}
adminWorkspaceLoaded.value = store.apiMode === 'api'
} finally {
adminWorkspaceLoading.value = false
}
}
watch(
() => [authStore.hydrated, authStore.canAccessAdmin, authStore.canManageAdminWorkspace, route.path] as const,
() => {
void ensureAdminWorkspace()
},
{ immediate: true },
)
</script>
<template>
<div class="pb-10">
<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">
<main class="order-1 min-w-0 xl:order-2">
<RouterView />
</main>
<aside class="order-2 space-y-3 xl:order-1 xl:sticky xl:top-4 xl:h-fit">
<Card class="p-3">
<nav class="space-y-4">
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
@@ -133,8 +175,6 @@ onMounted(async () => {
</div>
</Card>
</aside>
<RouterView />
</div>
</div>
</template>
@@ -1,17 +1,21 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { ClipboardList, Search, Sparkles, Tags, Users } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
import AdminNominationReviewModal from '../../components/admin/AdminNominationReviewModal.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 route = useRoute()
const router = useRouter()
const query = ref('')
const categoryFilter = ref<number | null>(null)
const statusFilter = ref<'all' | 'selected' | 'empty-category' | 'heavy'>('all')
const reviewModalOpen = ref(false)
const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
@@ -48,19 +52,49 @@ const nominationStats = computed(() => [
{ label: 'Betroffene Kategorien', value: categoryStats.value.filter((category) => category.pending > 0).length, icon: Tags },
{ label: 'Kandidatenbasis', value: seasonDetail.value.candidates.length, icon: Users },
])
const reviewFocusCategories = computed(() => categoryStats.value.filter((category) => category.pending > 0).slice(0, 3))
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: seasonDetail.value.pendingNominations.length },
{ key: 'empty-category' as const, label: 'Ohne Kandidatenbasis', count: seasonDetail.value.pendingNominations.filter((nomination) => !seasonDetail.value.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)).length },
{ key: 'heavy' as const, label: 'Hoher Druck', count: categoryStats.value.filter((category) => category.pending >= 3).reduce((sum, category) => sum + category.pending, 0) },
])
function openReviewModal(nominationId?: number) {
const nextQuery: LocationQueryRaw = { ...route.query, review: '1' }
if (nominationId) {
nextQuery.nominationId = String(nominationId)
} else {
delete nextQuery.nominationId
}
reviewModalOpen.value = true
void router.replace({ name: 'admin-nominations', query: nextQuery })
}
function closeReviewModal() {
const restQuery: LocationQueryRaw = { ...route.query }
delete restQuery.review
delete restQuery.nominationId
reviewModalOpen.value = false
void router.replace({ name: 'admin-nominations', query: restQuery })
}
watch(
() => [route.query.review, route.query.nominationId] as const,
([review, nominationId]) => {
reviewModalOpen.value = Boolean(review || nominationId)
},
{ immediate: true },
)
</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."
description="Nominierungen sichten, Kategorien priorisieren und Review-Fälle fokussiert entscheiden."
:icon="ClipboardList"
/>
@@ -80,11 +114,41 @@ const statusFilters = computed(() => [
</Card>
</section>
<Card class="overflow-hidden border-amber-100 bg-amber-50/55">
<div class="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-amber-700">Review-Fokus</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">
{{ seasonDetail.pendingNominations.length }} offene Entscheidungen
</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-600">
Die Nominierungsseite bleibt eine Übersicht. Der Review-Fokus öffnet die Queue mit Entscheidungspanel, ohne die Liste dauerhaft aufzublähen.
</p>
<div v-if="reviewFocusCategories.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="category in reviewFocusCategories"
:key="category.id"
class="rounded-full border border-amber-200 bg-white/75 px-3 py-1 text-xs font-semibold text-amber-800"
>
{{ category.name }} · {{ category.pending }}
</span>
</div>
</div>
<button
type="button"
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"
@click="openReviewModal()"
>
Review-Fokus öffnen
</button>
</div>
</Card>
<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>
<h2 class="mt-1 text-xl font-bold text-slate-900">Wo staut es sich?</h2>
</div>
<div class="divide-y divide-violet-50">
<button
@@ -115,9 +179,13 @@ const statusFilters = computed(() => [
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>
<button
type="button"
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"
@click="openReviewModal()"
>
Review-Fokus
</button>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
@@ -152,6 +220,13 @@ const statusFilters = computed(() => [
>
erst Kandidatenbasis klären
</span>
<button
type="button"
class="inline-flex h-9 items-center justify-center rounded-full bg-violet-600 px-4 text-xs font-semibold text-white shadow-sm shadow-violet-500/20 transition hover:bg-violet-500"
@click="openReviewModal(nomination.id)"
>
Fall entscheiden
</button>
</div>
</div>
<p v-if="filteredNominations.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
@@ -160,5 +235,7 @@ const statusFilters = computed(() => [
</div>
</Card>
</section>
<AdminNominationReviewModal :open="reviewModalOpen" @close="closeReviewModal" />
</div>
</template>
+58 -292
View File
@@ -1,323 +1,89 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Search, Sparkles, Trash2 } from '@lucide/vue'
import { Sparkles } from '@lucide/vue'
import AdminReviewDecisionPanel from '../../components/admin/AdminReviewDecisionPanel.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminReviewsHistorySection from '../../components/admin/AdminReviewsHistorySection.vue'
import AdminReviewsQueueHeader from '../../components/admin/AdminReviewsQueueHeader.vue'
import AdminReviewsQueueList from '../../components/admin/AdminReviewsQueueList.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'
import { useAdminReviewsManager } from '../../components/admin/useAdminReviewsManager'
const store = useAwardsStore()
const reviewSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const reviewForms = reactive<Record<number, {
displayName: string
channelSlug: string
platform: string
}>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const reviewFilter = ref('')
const categoryFilter = ref<number | null>(null)
const selectedNominationId = ref<number | null>(null)
const filteredNominations = computed(() => {
const query = reviewFilter.value.trim().toLowerCase()
return seasonDetail.value.pendingNominations.filter((nomination) =>
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
.join(' ')
.toLowerCase()
.includes(query)),
)
})
const selectedNomination = computed(() =>
filteredNominations.value.find((nomination) => nomination.id === selectedNominationId.value) ?? filteredNominations.value[0] ?? null,
)
const reviewStats = computed(() => [
{ label: 'Offen', value: seasonDetail.value.pendingNominations.length },
{ label: 'Sichtbar', value: filteredNominations.value.length },
{ label: 'Kategorien', value: new Set(seasonDetail.value.pendingNominations.map((nomination) => nomination.categoryName)).size },
])
const categoryOptions = computed(() =>
seasonDetail.value.categories
.filter((category) => seasonDetail.value.pendingNominations.some((nomination) => nomination.categoryId === category.id))
.map((category) => ({
id: category.id,
label: category.name,
count: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
})),
)
const selectedCandidateCollision = computed(() => {
if (!selectedNomination.value) return null
const form = reviewForms[selectedNomination.value.id]
if (!form) return null
const normalizedName = form.displayName.trim().toLowerCase()
const normalizedSlug = form.channelSlug.trim().toLowerCase()
return seasonDetail.value.candidates.find((candidate) =>
candidate.categoryId === selectedNomination.value?.categoryId &&
(candidate.displayName.trim().toLowerCase() === normalizedName || (!!normalizedSlug && candidate.channelSlug.trim().toLowerCase() === normalizedSlug)),
) ?? null
})
const canApproveSelected = computed(() => {
if (!selectedNomination.value) return false
const form = reviewForms[selectedNomination.value.id]
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim())
})
watch(
const {
reviewSaving,
adminMessage,
adminError,
reviewForms,
seasonDetail,
(detail) => {
for (const nomination of detail.pendingNominations) {
reviewForms[nomination.id] = {
displayName: nomination.candidateText,
channelSlug: '',
platform: 'Twitch',
}
}
if (!detail.pendingNominations.some((nomination) => nomination.id === selectedNominationId.value)) {
selectedNominationId.value = detail.pendingNominations[0]?.id ?? null
}
},
{ immediate: true },
)
watch(
reviewFilter,
categoryFilter,
selectedNominationId,
candidatePlatformOptions,
filteredNominations,
(nominations) => {
if (!nominations.some((nomination) => nomination.id === selectedNominationId.value)) {
selectedNominationId.value = nominations[0]?.id ?? null
}
},
{ immediate: true },
)
async function approveNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
adminMessage.value = 'Nominierung wurde in die Kandidatenliste übernommen.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht übernommen werden.'
} finally {
reviewSaving.value = null
}
}
async function rejectNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
adminMessage.value = 'Nominierung wurde aus der Review-Liste entfernt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
} finally {
reviewSaving.value = null
}
}
function setPlatform(platform: string) {
if (!selectedNomination.value) return
reviewForms[selectedNomination.value.id].platform = platform
}
selectedNomination,
reviewStats,
reviewedNominations,
categoryOptions,
selectedCandidateCollision,
canApproveSelected,
approveNomination,
rejectNomination,
selectedPlatformValue,
handlePlatformSelection,
} = useAdminReviewsManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Reviews"
title="Freitext-Nominierungen sichten"
description="Alle uneindeutigen oder noch nicht gemappten Nominierungen laufen hier zusammen. Jede Entscheidung muss einen vollständigen Kandidaten-Datensatz erzeugen oder den Fall bewusst verwerfen."
description="Freitext-Nominierungen annehmen, in Kandidaten umwandeln oder verwerfen."
:icon="Sparkles"
/>
<AdminSeasonToolbar />
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-white/75 p-6">
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Offene Nominierungen</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
</p>
</div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
<div v-for="stat in reviewStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<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="reviewFilter"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Nach Kategorie, Kandidat oder Nutzer suchen"
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
{{ filteredNominations.length }} / {{ seasonDetail.pendingNominations.length }} sichtbar
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="categoryFilter === null ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="categoryFilter = null"
>
Alle Kategorien
</button>
<button
v-for="category in categoryOptions"
:key="category.id"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="categoryFilter === category.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="categoryFilter = category.id"
>
{{ category.label }} · {{ category.count }}
</button>
</div>
</div>
<AdminReviewsQueueHeader
v-model:review-filter="reviewFilter"
v-model:category-filter="categoryFilter"
:total-pending="seasonDetail.pendingNominations.length"
:visible-count="filteredNominations.length"
:review-stats="reviewStats"
:category-options="categoryOptions"
/>
<div class="space-y-4 p-6">
<p v-if="adminMessage" class="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="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="grid gap-5 xl:grid-cols-[minmax(320px,0.85fr)_minmax(0,1.15fr)]">
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
<button
v-for="nomination in filteredNominations"
:key="nomination.id"
type="button"
class="w-full rounded-2xl border p-3 text-left transition"
:class="selectedNomination?.id === nomination.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:border-violet-200 hover:bg-violet-50/50'"
@click="selectedNominationId = nomination.id"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700">
{{ nomination.categoryName }}
</span>
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
ID {{ nomination.id }}
</span>
</div>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
<p class="mt-1 truncate text-sm text-slate-500">
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
</div>
</button>
<AdminReviewsQueueList
:nominations="filteredNominations"
:total-pending="seasonDetail.pendingNominations.length"
:selected-nomination-id="selectedNominationId"
@select="selectedNominationId = $event"
/>
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Review-Fälle im aktuell gewählten Award-Jahr.
</p>
<p v-else-if="filteredNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Review-Fälle passen zum aktuellen Filter.
</p>
</div>
<div v-if="selectedNomination" class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ selectedNomination.categoryName }}</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedNomination.candidateText }}</h3>
<p class="mt-2 text-sm text-slate-500">
Eingereicht von {{ selectedNomination.submittedByTwitchId }} · {{ new Date(selectedNomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
ID {{ selectedNomination.id }}
</div>
</div>
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p>
<div class="mt-4 grid gap-4 md:grid-cols-3">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input
v-model="reviewForms[selectedNomination.id].displayName"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Anzeigename"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input
v-model="reviewForms[selectedNomination.id].channelSlug"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="@channel"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<input
v-model="reviewForms[selectedNomination.id].platform"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Twitch"
/>
</label>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="platform in ['Twitch', 'YouTube', 'TikTok']"
:key="platform"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="reviewForms[selectedNomination.id].platform === platform ? 'border-violet-200 bg-white text-violet-800' : 'border-violet-100 bg-white/70 text-slate-600 hover:bg-white'"
@click="setPlatform(platform)"
>
{{ platform }}
</button>
</div>
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden. Übernimm nur, wenn es wirklich ein separater Kandidat ist; Alias-/Merge-Pflege gehört danach in Kandidaten.
</p>
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
Anzeigename, Handle und Plattform sind Pflicht, damit der Kandidat später im Voting eindeutig erscheint.
</p>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="reviewSaving === selectedNomination.id" variant="secondary" @click="rejectNomination(selectedNomination.id)">
<Trash2 class="mr-2 h-4 w-4" />
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="reviewSaving === selectedNomination.id || !canApproveSelected" @click="approveNomination(selectedNomination.id)">
<CheckCircle2 class="mr-2 h-4 w-4" />
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
</Button>
</div>
</div>
<AdminReviewDecisionPanel
:nomination="selectedNomination"
:review-saving="reviewSaving"
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
:candidate-platform-options="candidatePlatformOptions"
:selected-candidate-collision="selectedCandidateCollision"
:can-approve-selected="canApproveSelected"
:selected-platform-value="selectedPlatformValue"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
/>
</div>
<AdminReviewsHistorySection
:reviewed-nominations="reviewedNominations"
:reviewed-total="seasonDetail.reviewedNominations.length"
/>
</div>
</Card>
</div>
+124 -157
View File
@@ -1,177 +1,144 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Search, ShieldAlert } from '@lucide/vue'
import { ShieldAlert } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Button from '../../components/ui/Button.vue'
import AdminRiskDecisionPanel from '../../components/admin/AdminRiskDecisionPanel.vue'
import AdminRiskHistorySection from '../../components/admin/AdminRiskHistorySection.vue'
import AdminRiskOverviewBoard from '../../components/admin/AdminRiskOverviewBoard.vue'
import AdminRiskQueueList from '../../components/admin/AdminRiskQueueList.vue'
import AdminRiskRulesEditor from '../../components/admin/AdminRiskRulesEditor.vue'
import { useAdminRiskManager } from '../../components/admin/useAdminRiskManager'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const riskSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const riskFilter = ref('')
const severityFilter = ref<'all' | 'high' | 'medium' | 'low'>('all')
const riskFlags = computed(() => store.admin.riskFlags)
const filteredRiskFlags = computed(() => {
const query = riskFilter.value.trim().toLowerCase()
return riskFlags.value.filter((flag) =>
(severityFilter.value === 'all' || flag.severity.toLowerCase() === severityFilter.value) &&
(!query || [flag.source, flag.type, flag.summary, flag.twitchUserId ?? '', flag.createdFromIp]
.join(' ')
.toLowerCase()
.includes(query)),
)
})
const riskStats = computed(() => [
{ label: 'Offen', value: riskFlags.value.length },
{ label: 'High', value: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length },
{ label: 'User betroffen', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
])
const severityFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: riskFlags.value.length },
{ key: 'high' as const, label: 'High', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length },
{ key: 'medium' as const, label: 'Medium', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'medium').length },
{ key: 'low' as const, label: 'Low', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'low').length },
])
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
riskSaving.value = riskFlagId
adminMessage.value = ''
adminError.value = ''
try {
await store.resolveRiskFlag(riskFlagId, status)
adminMessage.value = `Risikohinweis ${riskFlagId} wurde aktualisiert.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Risikohinweis konnte nicht aktualisiert werden.'
} finally {
riskSaving.value = null
}
}
const {
riskSaving,
riskLoading,
adminMessage,
adminError,
riskFilter,
severityFilter,
historyStatusFilter,
selectedRiskFlagId,
selectedDecisionNote,
selectedDecisionNoteLength,
selectedDecisionReady,
queuePage,
queuePageLabel,
queueHasPrevious,
queueHasMore,
historyPage,
historyPageLabel,
historyHasPrevious,
historyHasMore,
selectedBulkRiskFlagIds,
bulkReviewNote,
bulkSaving,
canBulkResolve,
riskRules,
riskRulesLoading,
riskRulesSaving,
riskFlags,
riskHistory,
filteredRiskFlags,
selectedRiskFlag,
selectedRiskMetadata,
riskStats,
riskHistoryStats,
recentRiskHistory,
severityFilters,
historyStatusFilters,
riskLoadedLabel,
loadRiskFlags,
updateRiskFlagStatus,
setQueuePage,
setHistoryPage,
toggleBulkRiskFlag,
selectVisibleLowRiskFlags,
clearBulkSelection,
bulkResolveRiskFlags,
updateRiskRule,
saveRiskRules,
} = useAdminRiskManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Risiko"
title="Auffällige Muster entscheiden"
description="Dieser Bereich ist nur für operative Risiko-Sichtung zuständig: Voting-, Login- und Einreichungsmuster prüfen, verwerfen oder erledigt markieren. Audit-Logs liegen separat im Team-Audit."
description="Auffällige Voting-, Login- und Einreichungsmuster prüfen."
:icon="ShieldAlert"
/>
<div class="grid gap-6 xl:grid-cols-[0.82fr_1.18fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikoprüfung</h2>
<p class="mt-2 text-sm text-slate-500">Auffällige Login-, Nominierungs- und Voting-Muster für die manuelle Sichtung.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen
</span>
</div>
<div class="mt-5 grid gap-2 sm:grid-cols-3">
<div v-for="stat in riskStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
<Card class="overflow-hidden">
<AdminRiskOverviewBoard
v-model:risk-filter="riskFilter"
v-model:severity-filter="severityFilter"
:filtered-count="filteredRiskFlags.length"
:total-open="riskFlags.length"
:loaded-label="riskLoadedLabel"
:loading="riskLoading"
:message="adminMessage"
:error="adminError"
:stats="riskStats"
:severity-filters="severityFilters"
@refresh="loadRiskFlags"
/>
<p v-if="adminMessage" class="mt-6 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-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
<div class="grid gap-5 p-6 xl:grid-cols-[minmax(300px,0.78fr)_minmax(0,1.22fr)]">
<AdminRiskQueueList
:risk-flags="filteredRiskFlags"
:total-open="riskFlags.length"
:selected-risk-flag-id="selectedRiskFlagId"
:page="queuePage"
:page-label="queuePageLabel"
:has-previous="queueHasPrevious"
:has-more="queueHasMore"
:selected-bulk-risk-flag-ids="selectedBulkRiskFlagIds"
:bulk-review-note="bulkReviewNote"
:bulk-saving="bulkSaving"
:can-bulk-resolve="canBulkResolve"
@select="selectedRiskFlagId = $event"
@page="setQueuePage"
@toggle-bulk="toggleBulkRiskFlag"
@select-visible-low="selectVisibleLowRiskFlags"
@clear-bulk="clearBulkSelection"
@update:bulk-review-note="bulkReviewNote = $event"
@bulk-decide="bulkResolveRiskFlags"
/>
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<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="riskFilter"
type="text"
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="Typ, Nutzer oder IP filtern"
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
Tipp: Filtere erst auf den Problemtyp und markiere dann nur den geprüften Fall.
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="filter in severityFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="severityFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="severityFilter = filter.key"
>
{{ filter.label }} · {{ filter.count }}
</button>
</div>
<AdminRiskDecisionPanel
v-model:decision-note="selectedDecisionNote"
:risk-flag="selectedRiskFlag"
:metadata-items="selectedRiskMetadata"
:decision-note-length="selectedDecisionNoteLength"
:decision-ready="selectedDecisionReady"
:saving="riskSaving"
@decide="updateRiskFlagStatus"
/>
</div>
<div class="mt-6 space-y-4">
<div
v-for="flag in filteredRiskFlags"
:key="flag.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ flag.source }} · {{ flag.type }}</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ flag.summary }}</h3>
<p class="mt-2 text-sm text-slate-500">
{{ flag.twitchUserId || 'unbekannter User' }} · {{ flag.createdFromIp }} · {{ new Date(flag.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold uppercase tracking-[0.2em] text-slate-600">
{{ flag.severity }}
</div>
</div>
<AdminRiskRulesEditor
:rules="riskRules"
:loading="riskRulesLoading"
:saving="riskRulesSaving"
@update-rule="updateRiskRule"
@save="saveRiskRules"
/>
<pre class="mt-4 overflow-x-auto rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-xs text-slate-600">{{ flag.metadataJson }}</pre>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="riskSaving === flag.id" variant="secondary" @click="resolveRiskFlag(flag.id, 'dismissed')">
{{ riskSaving === flag.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="riskSaving === flag.id" @click="resolveRiskFlag(flag.id, 'resolved')">
{{ riskSaving === flag.id ? 'Speichert ...' : 'Erledigt markieren' }}
</Button>
</div>
</div>
<p v-if="riskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Risikohinweise vorhanden.
</p>
<p v-else-if="filteredRiskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Risikohinweise passen zum aktuellen Filter.
</p>
</div>
</Card>
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review-Protokoll</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Risk Playbook</h2>
<div class="mt-6 space-y-3">
<div class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<p class="font-semibold text-slate-900">1. Quelle prüfen</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Vote-Flags vor Ergebnisfreigabe priorisieren, Clip-Flags vor Public-Einbindung, Login-Flags bei wiederholten IP-Mustern.</p>
</div>
<div class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<p class="font-semibold text-slate-900">2. Entscheidung dokumentieren</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Erledigt bedeutet geprüft und relevant; Verwerfen bedeutet false positive oder kein Award-Risiko.</p>
</div>
<div class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<p class="font-semibold text-slate-900">3. Audit separat lesen</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Admin-Aktionen findest du im Team-Audit, damit Risikoentscheidungen nicht mit normalen Bearbeitungen vermischt werden.</p>
</div>
</div>
</Card>
</div>
<AdminRiskHistorySection
v-model:history-status-filter="historyStatusFilter"
:risk-history="recentRiskHistory"
:total-history="riskHistory.length"
:history-status-filters="historyStatusFilters"
:history-stats="riskHistoryStats"
:saving="riskSaving"
:page="historyPage"
:page-label="historyPageLabel"
:has-previous="historyHasPrevious"
:has-more="historyHasMore"
@decide="updateRiskFlagStatus"
@page="setHistoryPage"
/>
</Card>
</div>
</template>
+105 -210
View File
@@ -1,230 +1,125 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { CalendarCog, CheckCircle2, Clock3, Layers3, ShieldCheck, Tags, Users } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { CalendarCog } from '@lucide/vue'
import AdminAwardYearsPanel from '../../components/admin/AdminAwardYearsPanel.vue'
import AdminSeasonCreateModal from '../../components/admin/AdminSeasonCreateModal.vue'
import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModal.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'
import AdminSeasonPhaseSwitcher from '../../components/admin/AdminSeasonPhaseSwitcher.vue'
import AdminSeasonStatusCard from '../../components/admin/AdminSeasonStatusCard.vue'
import AdminSeasonTimelineEditor from '../../components/admin/AdminSeasonTimelineEditor.vue'
import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonManager'
const store = useAwardsStore()
const saving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const form = reactive({
currentPhase: '',
isCurrent: false,
})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const selectedSeason = computed(() =>
store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
)
const seasonHealth = computed(() => {
const emptyCategories = seasonDetail.value.categories.filter((category) =>
!seasonDetail.value.candidates.some((candidate) => candidate.categoryId === category.id),
).length
return [
{
label: 'Kategorien',
value: seasonDetail.value.categories.length,
note: emptyCategories === 0 ? 'alle mit Kandidatenbasis' : `${emptyCategories} ohne Kandidaten`,
icon: Tags,
to: '/admin/categories',
},
{
label: 'Kandidaten',
value: seasonDetail.value.candidates.length,
note: 'für Public Voting und Archiv',
icon: Users,
to: '/admin/candidates',
},
{
label: 'Offene Reviews',
value: seasonDetail.value.pendingNominations.length,
note: 'vor Voting-Freeze entscheiden',
icon: ShieldCheck,
to: '/admin/reviews',
},
]
})
const phasePresets = ['Vorbereitung', 'Nominierung', 'Community Voting', 'Auswertung', 'Award Show', 'Archiviert']
watch(
const {
store,
form,
createForm,
saving,
adminMessage,
adminError,
createModalOpen,
creating,
completing,
seasonToDelete,
deleting,
seasonDetail,
(detail) => {
form.currentPhase = detail.currentPhase
form.isCurrent = detail.isCurrent
},
{ immediate: true },
)
async function saveSeason() {
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 = 'Jahresstatus gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Jahr konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
selectedSeasonId,
selectedSeason,
readinessItems,
archiveReadinessIssues,
createPublicReadinessIssues,
canActivatePublic,
phasePresets,
canDeleteSelectedSeason,
canCompleteSelectedSeason,
copySourceOptions,
loadingSeasonAudit,
latestSeasonAuditSummary,
latestSeasonAuditMeta,
canCreate,
activatePhase,
openCreateModal,
saveSeason,
completeSeason,
createSeason,
openDeleteSeasonModal,
confirmDeleteSeason,
} = useAdminSeasonManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Jahre"
title="Award-Jahr steuern"
description="Hier liegt nur die Season-Verantwortung: Jahr auswählen, Phase setzen und entscheiden, welches Jahr öffentlich sichtbar ist. Kategorien und Kandidaten bleiben in ihren eigenen Arbeitsbereichen."
description="Jahr, Phase und öffentliche Sichtbarkeit steuern."
:icon="CalendarCog"
/>
<AdminSeasonToolbar />
<section class="space-y-6">
<AdminSeasonPhaseSwitcher
:form="form"
:season-name="seasonDetail.name"
:saving="saving"
:selected-season-id="selectedSeasonId"
:activate-phase="activatePhase"
/>
<section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Jahresstatus</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ seasonDetail.name || 'Kein Jahr gewählt' }}</h2>
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
Der Status steuert die Admin-Orientierung und den Public-Kontext. Inhaltliche Pflege passiert über Kategorien, Kandidaten, Reviews und Clips.
</p>
</div>
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="form.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
{{ form.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
</div>
</div>
</div>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<AdminSeasonStatusCard
:form="form"
:season-name="seasonDetail.name"
:saving="saving"
:completing="completing"
:admin-message="adminMessage"
:admin-error="adminError"
:readiness-items="readinessItems"
:archive-readiness-issues="archiveReadinessIssues"
:can-activate-public="canActivatePublic"
:loading-season-audit="loadingSeasonAudit"
:latest-season-audit-summary="latestSeasonAuditSummary"
:latest-season-audit-meta="latestSeasonAuditMeta"
:selected-season-id="selectedSeasonId"
:can-delete-selected-season="canDeleteSelectedSeason"
:can-complete-selected-season="canCompleteSelectedSeason"
:selected-season-is-current="selectedSeason?.isCurrent ?? false"
:open-delete-season-modal="openDeleteSeasonModal"
:save-season="saveSeason"
:complete-season="completeSeason"
/>
<div class="space-y-5 p-6">
<div class="grid gap-3 sm:grid-cols-2">
<button
v-for="phase in phasePresets"
:key="phase"
type="button"
class="rounded-2xl border px-4 py-3 text-left text-sm font-semibold transition"
:class="form.currentPhase === phase ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="form.currentPhase = phase"
>
{{ phase }}
</button>
</div>
<AdminAwardYearsPanel
:seasons="store.adminSeasons"
:selected-season-id="selectedSeasonId"
:open-create-modal="openCreateModal"
:load-season-detail="store.loadAdminSeasonDetail"
/>
</section>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aktuelle Phase</span>
<input
v-model="form.currentPhase"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z.B. 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-800">Dieses Award-Jahr öffentlich schalten</span>
<span class="mt-1 block text-sm leading-6 text-slate-500">
Nur ein Award-Jahr sollte gleichzeitig als Public-Kontext aktiv sein.
</span>
</span>
</label>
<p v-if="adminMessage" class="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="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="flex justify-end border-t border-violet-100 pt-5">
<Button :disabled="saving || !selectedSeasonId" @click="saveSeason">
{{ saving ? 'Speichert ...' : 'Jahresstatus speichern' }}
</Button>
</div>
</div>
</Card>
<Card class="p-6">
<div class="flex items-start gap-4">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<Layers3 class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Season Snapshot</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ seasonDetail.year || '-' }}</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
Schneller Überblick, ob das gewählte Jahr bereit für die nächste Award-Phase ist.
</p>
</div>
</div>
<div class="mt-6 space-y-3">
<RouterLink
v-for="item in seasonHealth"
:key="item.label"
:to="item.to"
class="flex items-center justify-between gap-4 rounded-[22px] border border-violet-100 bg-white/90 p-4 transition hover:bg-violet-50/50"
>
<div class="flex min-w-0 items-center gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="item.icon" class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="font-semibold text-slate-900">{{ item.label }}</p>
<p class="truncate text-sm text-slate-500">{{ item.note }}</p>
</div>
</div>
<strong class="text-xl text-violet-800">{{ item.value }}</strong>
</RouterLink>
</div>
</Card>
<AdminSeasonTimelineEditor
:form="form"
:saving="saving"
:selected-season-id="selectedSeasonId"
:save-season="saveSeason"
/>
</section>
<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">Alle Jahre</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Season-Liste</h2>
</div>
<div class="divide-y divide-violet-50">
<button
v-for="season in store.adminSeasons"
:key="season.id"
type="button"
class="grid w-full gap-3 px-5 py-4 text-left transition hover:bg-violet-50/50 md:grid-cols-[120px_minmax(0,1fr)_180px_120px] md:items-center"
:class="selectedSeason?.id === season.id ? 'bg-violet-50/80' : ''"
@click="store.loadAdminSeasonDetail(season.id)"
>
<strong class="text-violet-800">{{ season.year }}</strong>
<span class="min-w-0">
<span class="block truncate font-semibold text-slate-900">{{ season.name }}</span>
<span class="mt-1 block truncate text-sm text-slate-500">{{ season.categoryCount }} Kategorien</span>
</span>
<span class="inline-flex w-fit items-center gap-2 rounded-full border border-violet-100 bg-white px-3 py-1 text-xs font-semibold text-slate-600">
<Clock3 class="h-3.5 w-3.5 text-violet-500" />
{{ season.currentPhase }}
</span>
<span class="inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold" :class="season.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
<CheckCircle2 class="h-3.5 w-3.5" />
{{ season.isCurrent ? 'Public' : 'Intern' }}
</span>
</button>
<p v-if="store.adminSeasons.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Noch keine Award-Jahre aus der API geladen.
</p>
</div>
</Card>
</div>
<AdminSeasonCreateModal
:open="createModalOpen"
:create-form="createForm"
:creating="creating"
:can-create="canCreate"
:copy-source-options="copySourceOptions"
:create-public-readiness-issues="createPublicReadinessIssues"
:phase-presets="phasePresets"
:on-close="() => { createModalOpen = false }"
:on-create="createSeason"
/>
<AdminSeasonDeleteModal
:season-to-delete="seasonToDelete"
:deleting="deleting"
:on-close="() => { seasonToDelete = null }"
:on-confirm-delete="confirmDeleteSeason"
/>
</template>
+88 -122
View File
@@ -1,143 +1,109 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircle2, Database, Settings, ShieldCheck, Tags, Vote } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { Settings } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import AdminOperationalSettingsCard from '../../components/admin/AdminOperationalSettingsCard.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'
import AdminSettingsDatabaseCard from '../../components/admin/AdminSettingsDatabaseCard.vue'
import AdminSettingsOverviewBoard from '../../components/admin/AdminSettingsOverviewBoard.vue'
import { useAuthStore } from '../../stores/auth'
import { useAdminOperationalSettings } from '../../components/admin/useAdminOperationalSettings'
import { useAdminSettingsOverview } from '../../components/admin/useAdminSettingsOverview'
const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const hasVotingPhase = computed(() => seasonDetail.value.currentPhase.toLowerCase().includes('voting'))
const categoriesWithoutCandidates = computed(() =>
seasonDetail.value.categories.filter((category) =>
!seasonDetail.value.candidates.some((candidate) => candidate.categoryId === category.id),
),
)
const pendingClips = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const authStore = useAuthStore()
const canManageOperationalSettings = computed(() => authStore.canManageOperationalSettings)
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,
to: null,
},
{
label: 'Public-Jahr gesetzt',
value: seasonDetail.value.isCurrent,
note: seasonDetail.value.isCurrent ? `${seasonDetail.value.year} ist öffentlich markiert.` : 'Das gewählte Jahr ist aktuell intern.',
icon: CheckCircle2,
to: '/admin/years',
},
{
label: 'Voting-Basis vollständig',
value: categoriesWithoutCandidates.value.length === 0 && seasonDetail.value.categories.length > 0,
note: categoriesWithoutCandidates.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${categoriesWithoutCandidates.value.length} Kategorien brauchen Kandidaten.`,
icon: Tags,
to: '/admin/categories',
},
{
label: 'Risiko-Queue leer',
value: store.admin.riskFlags.length === 0,
note: `${store.admin.riskFlags.length} offene Risikohinweise im Admin-Kontext.`,
icon: ShieldCheck,
to: '/admin/risk',
},
])
const gates = computed(() => [
{
label: 'Nominierungen',
state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'),
note: 'Aktiv, wenn die Season-Phase auf Nominierung steht.',
to: '/admin/years',
},
{
label: 'Voting',
state: hasVotingPhase.value && categoriesWithoutCandidates.value.length === 0,
note: hasVotingPhase.value ? 'Phase ist Voting; Kategorie-Readiness entscheidet.' : 'Phase ist nicht Voting.',
to: '/admin/voting',
},
{
label: 'Clip-Moderation',
state: pendingClips.value > 0,
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
to: '/admin/clips',
},
{
label: 'Review-Freeze',
state: seasonDetail.value.pendingNominations.length === 0,
note: `${seasonDetail.value.pendingNominations.length} offene Freitext-Reviews.`,
to: '/admin/reviews',
},
])
const {
healthLoading,
healthError,
databaseHealth,
pendingMigrationCount,
healthLoadedLabel,
contentChecks,
contentCompletion,
checks,
gates,
refreshDatabaseHealth,
} = useAdminSettingsOverview()
const {
operationalLoading,
operationalSaving,
operationalError,
operationalSuccess,
operationalForm,
demoPasswordSet,
demoManagedByDatabase,
demoPasswordInput,
demoPasswordHint,
demoCredentialsComplete,
operationalSummary,
hasUnsavedOperationalChanges,
saveOperationalSettings,
} = useAdminOperationalSettings()
function confirmDiscardOperationalChanges() {
if (!hasUnsavedOperationalChanges.value) {
return true
}
return window.confirm('Du hast ungespeicherte Änderungen in Demo & Wartung. Änderungen verwerfen?')
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedOperationalChanges.value) return
event.preventDefault()
event.returnValue = ''
}
onBeforeRouteLeave(() => confirmDiscardOperationalChanges())
onMounted(() => window.addEventListener('beforeunload', handleBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Einstellungen"
title="Systemchecks ohne Doppelpflege"
description="Diese Seite speichert keine Season-Daten mehr. Sie zeigt, ob API, Public-Jahr, Voting-Basis, Reviews, Clips und Risiko-Queue für den Award-Betrieb gesund sind."
description="Demo-Zugang, Wartungsmodus und Healthchecks."
:icon="Settings"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<RouterLink
v-for="check in checks"
:key="check.label"
:to="check.to ?? '/admin/settings'"
class="rounded-[26px] border bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.09)] transition hover:bg-violet-50/50"
:class="check.value ? 'border-emerald-100' : 'border-amber-100'"
>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="check.value ? 'text-emerald-600' : 'text-amber-600'">{{ check.label }}</p>
<p class="mt-3 text-sm leading-6 text-slate-600">{{ check.note }}</p>
</div>
<div class="grid h-10 w-10 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>
</RouterLink>
</section>
<AdminSettingsOverviewBoard
:checks="checks"
:gates="gates"
:content-checks="contentChecks"
:content-completion="contentCompletion"
/>
<Card class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Feature Gates</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was ist wirklich aktiv?</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Die Gates sind aus aktuellen Daten abgeleitet und verlinken zum Ort, an dem der Zustand behoben wird.
</p>
</div>
<Vote class="h-6 w-6 text-violet-500" />
</div>
<AdminOperationalSettingsCard
v-model:demo-password="demoPasswordInput"
:form="operationalForm"
:loading="operationalLoading"
:saving="operationalSaving"
:error="operationalError"
:success="operationalSuccess"
:demo-password-hint="demoPasswordHint"
:demo-password-set="demoPasswordSet"
:demo-managed-by-database="demoManagedByDatabase"
:demo-credentials-complete="demoCredentialsComplete"
:summary="operationalSummary"
:dirty="hasUnsavedOperationalChanges"
:can-manage="canManageOperationalSettings"
@save="saveOperationalSettings"
/>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<RouterLink
v-for="gate in gates"
:key="gate.label"
:to="gate.to"
class="rounded-[22px] border p-4 transition hover:bg-violet-50/50"
:class="gate.state ? 'border-emerald-100 bg-emerald-50/40' : 'border-slate-100 bg-slate-50/70'"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ gate.label }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ gate.note }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="gate.state ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600'">
{{ gate.state ? 'aktiv' : 'inaktiv' }}
</span>
</div>
</RouterLink>
</div>
</Card>
<AdminSettingsDatabaseCard
:health-loading="healthLoading"
:health-error="healthError"
:database-health="databaseHealth"
:pending-migration-count="pendingMigrationCount"
:health-loaded-label="healthLoadedLabel"
:refresh-database-health="refreshDatabaseHealth"
/>
</div>
</template>
+84 -108
View File
@@ -1,126 +1,102 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { FileClock, Search, UserCog } from '@lucide/vue'
import { UserCog } from '@lucide/vue'
import AdminAuditDetailDrawer from '../../components/admin/AdminAuditDetailDrawer.vue'
import AdminAuditFocusPanel from '../../components/admin/AdminAuditFocusPanel.vue'
import AdminAuditLogList from '../../components/admin/AdminAuditLogList.vue'
import AdminAuditOverviewBar from '../../components/admin/AdminAuditOverviewBar.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
import { useAdminAuditManager } from '../../components/admin/useAdminAuditManager'
const store = useAwardsStore()
const query = ref('')
const auditEntries = computed(() => store.admin.auditEntries)
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 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 }))
})
const logStats = computed(() => [
{ label: 'Audit-Einträge', value: auditEntries.value.length },
{ label: 'Admins aktiv', value: adminCounts.value.length },
{ label: 'Sichtbar', value: filteredAuditEntries.value.length },
])
const actionCounts = computed(() => {
const counts = new Map<string, number>()
for (const entry of auditEntries.value) counts.set(entry.actionType, (counts.get(entry.actionType) ?? 0) + 1)
return [...counts.entries()]
.map(([action, count]) => ({ action, count }))
.sort((a, b) => b.count - a.count || a.action.localeCompare(b.action))
})
const {
query,
selectedAdmin,
selectedAction,
entityFilter,
fromDate,
toDate,
loadingAudit,
loadingMore,
auditError,
exportMessage,
selectedEntry,
auditRows,
adminCounts,
actionCounts,
entityCounts,
logStats,
focusCards,
activeFilterCount,
lastLoadedLabel,
emptyStateText,
pageSummaryLabel,
hasMore,
filterPresets,
appliedPresetKey,
entityOptions,
loadAuditEntries,
loadNextPage,
exportAuditCsv,
clearFilters,
applyPreset,
openAuditEntry,
closeAuditEntry,
} = useAdminAuditManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Team-Audit"
title="Admin-Aktionen nachvollziehen"
description="Diese Seite ist die Log-Quelle für Team-Handlungen: wer hat Kategorien, Kandidaten, Clips, Reviews oder Risk-Flags bearbeitet. Risikoentscheidungen selbst bleiben im Risiko-Bereich."
eyebrow="Audit-Log"
description="Nachvollziehbare Admin-Aktionen mit Suche, Metadaten, Quick-Filtern und CSV-Export."
:icon="UserCog"
/>
<Card class="p-5">
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_420px]">
<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>
<div class="grid grid-cols-3 gap-2">
<div v-for="stat in logStats" :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>
</Card>
<AdminAuditOverviewBar
v-model:query="query"
v-model:entity-filter="entityFilter"
v-model:from-date="fromDate"
v-model:to-date="toDate"
:stats="logStats"
:entity-options="entityOptions"
:filter-presets="filterPresets"
:applied-preset-key="appliedPresetKey"
:export-message="exportMessage"
:last-loaded-label="lastLoadedLabel"
:loading="loadingAudit"
:has-rows="auditRows.length > 0"
:active-filter-count="activeFilterCount"
@refresh="loadAuditEntries()"
@export="exportAuditCsv"
@clear="clearFilters"
@apply-preset="applyPreset"
/>
<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">Aktivität</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-Aktivität vorhanden.
</p>
</div>
</Card>
<p v-if="auditError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ auditError }}</p>
<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-Einträge gefunden.</p>
</div>
</Card>
<section class="grid gap-6 2xl:grid-cols-[360px_minmax(0,1fr)]">
<AdminAuditFocusPanel
v-model:selected-admin="selectedAdmin"
v-model:selected-action="selectedAction"
v-model:selected-entity="entityFilter"
:focus-cards="focusCards"
:admin-counts="adminCounts"
:action-counts="actionCounts"
:entity-counts="entityCounts"
/>
<AdminAuditLogList
:entries="auditRows"
:loading="loadingAudit"
:loading-more="loadingMore"
:has-more="hasMore"
:page-summary-label="pageSummaryLabel"
:empty-text="emptyStateText"
@open-detail="openAuditEntry"
@load-more="loadNextPage"
/>
</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-violet-100 text-violet-700">
<FileClock class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktionstypen</p>
<h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Was wurde bearbeitet?</h2>
</div>
</div>
</div>
<div class="divide-y divide-violet-50">
<div v-for="item in actionCounts" :key="item.action" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_120px] lg:items-center">
<div>
<p class="font-semibold text-slate-900">{{ item.action }}</p>
<p class="mt-1 text-sm text-slate-500">Audit-Kategorie für Team-Aktionen</p>
</div>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-center text-sm font-semibold text-violet-700">{{ item.count }}</span>
</div>
<p v-if="actionCounts.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Noch keine Aktionstypen vorhanden.
</p>
</div>
</Card>
<AdminAuditDetailDrawer :entry="selectedEntry" @close="closeAuditEntry" />
</div>
</template>
@@ -1,161 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircle2, LockKeyhole, ShieldAlert, Tags, Vote } 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 { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
const votingReadiness = computed(() =>
seasonDetail.value.categories.map((category) => {
const candidateCount = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length
const reviewCount = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
return {
...category,
candidateCount,
reviewCount,
ready: candidateCount > 0 && reviewCount === 0 && seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
}
}),
)
const readyCount = computed(() => votingReadiness.value.filter((category) => category.ready).length)
const notReadyCategories = computed(() => votingReadiness.value.filter((category) => !category.ready))
const lockedCategories = computed(() =>
votingReadiness.value.filter((category) => category.candidateCount === 0 || category.reviewCount > 0),
)
const stats = computed(() => [
{ label: 'Stimmen gesamt', value: totalVotes.value, icon: Vote },
{ label: 'Voting-ready', value: readyCount.value, icon: CheckCircle2 },
{ label: 'Gesperrt', value: lockedCategories.value.length, icon: LockKeyhole },
{ label: 'Kategorien', value: seasonDetail.value.categories.length, icon: Tags },
])
const votingChecklist = computed(() => [
{
label: 'Voting-Phase aktiv',
done: seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
note: seasonDetail.value.currentPhase || 'Keine Phase gesetzt',
to: '/admin/settings',
},
{
label: 'Alle Kategorien haben Kandidaten',
done: votingReadiness.value.every((category) => category.candidateCount > 0) && seasonDetail.value.categories.length > 0,
note: `${notReadyCategories.value.filter((category) => category.candidateCount === 0).length} Kategorien ohne Kandidaten`,
to: '/admin/categories',
},
{
label: 'Offene Reviews niedrig',
done: seasonDetail.value.pendingNominations.length === 0,
note: `${seasonDetail.value.pendingNominations.length} offene Reviews`,
to: '/admin/reviews',
},
{
label: 'Risikohinweise geprüft',
done: store.admin.riskFlags.length === 0,
note: `${store.admin.riskFlags.length} offene Flags vor Ergebnisfreigabe`,
to: '/admin/risk',
},
])
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Voting"
title="Voting freigeben und absichern"
description="Diese Ansicht ist jetzt operativ: Phase, Kandidatenbasis, offene Reviews und Risiko-Flags entscheiden, ob eine Kategorie fürs Community Voting bereit ist."
: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="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 · {{ category.reviewCount }} Reviews</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' : 'prüfen' }}
</span>
</div>
</div>
</Card>
<Card class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Sperrgründe</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was blockiert?</h2>
</div>
<ShieldAlert class="h-6 w-6 text-amber-500" />
</div>
<div class="mt-5 space-y-3">
<RouterLink
v-for="category in lockedCategories"
:key="category.id"
:to="category.candidateCount === 0 ? '/admin/candidates' : '/admin/reviews'"
class="block rounded-[22px] border border-amber-100 bg-amber-50/50 p-4 transition hover:bg-amber-50"
>
<p class="font-semibold text-slate-900">{{ category.name }}</p>
<p class="mt-1 text-sm text-slate-600">
{{ category.candidateCount === 0 ? 'Keine Kandidaten gepflegt.' : `${category.reviewCount} offene Reviews vor Voting-Freigabe.` }}
</p>
</RouterLink>
<p v-if="lockedCategories.length === 0" class="rounded-[22px] border border-emerald-100 bg-emerald-50/50 px-5 py-6 text-sm text-emerald-700">
Keine Kategorie ist durch Inhalt oder Review-Backlog blockiert.
</p>
</div>
</Card>
</section>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Voting Checkliste</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Vor dem Public Push</h2>
<div class="mt-5 grid gap-3 lg:grid-cols-4">
<RouterLink
v-for="item in votingChecklist"
:key="item.label"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:-translate-y-0.5 hover:bg-violet-50/50"
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/50'"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ item.label }}</p>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.done ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'">
{{ item.done ? 'ok' : 'prüfen' }}
</span>
</div>
</RouterLink>
</div>
</Card>
</div>
</template>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { Award, CheckCircle2, Filter, Trash2, Trophy } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminWinnersManager } from '../../components/admin/useAdminWinnersManager'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
const {
adminError,
adminMessage,
completionPct,
deletingResultId,
query,
savingResultForCategory,
statusFilter,
statusFilters,
summaryCards,
visibleResultRows,
winnerSelections,
clearWinner,
saveWinner,
} = useAdminWinnersManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Gewinner"
description="Finale Gewinner je Kategorie setzen, aktualisieren und fuer Archiv sowie Public-Ansicht freigeben."
:icon="Trophy"
/>
<AdminSeasonToolbar />
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card v-for="card in summaryCards" :key="card.label" class="p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">{{ card.label }}</p>
<strong class="mt-2 block text-2xl text-slate-950">{{ card.value }}</strong>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ card.note }}</p>
</div>
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-xl border" :class="card.tone">
<component :is="card.icon" class="h-5 w-5" />
</div>
</div>
</Card>
</section>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-center">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Winner Lock-In</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Gewinner freigeben</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
Pro Kategorie kann genau ein Gewinner gesetzt werden. Offene Reviews werden sichtbar markiert, blockieren die technische Freigabe aber nicht.
</p>
</div>
<div>
<div class="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
<span>Fortschritt</span>
<span>{{ completionPct }}%</span>
</div>
<div class="mt-2 h-3 overflow-hidden rounded-full bg-violet-100">
<div class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#22c55e)] transition-all" :style="{ width: `${completionPct}%` }" />
</div>
</div>
</div>
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<label class="relative block">
<Filter class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="query"
type="search"
placeholder="Kategorie, Gewinner oder Kandidat suchen"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-10 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="option in statusFilters"
:key="option.value"
type="button"
class="inline-flex h-10 items-center gap-2 rounded-xl border px-3 text-sm font-semibold transition"
:class="statusFilter === option.value ? 'border-violet-200 bg-violet-600 text-white shadow-lg shadow-violet-500/20' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="statusFilter = option.value"
>
{{ option.label }}
<span class="rounded-full bg-white/25 px-2 py-0.5 text-xs">{{ option.count }}</span>
</button>
</div>
</div>
</div>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div v-if="visibleResultRows.length" class="divide-y divide-violet-50">
<article
v-for="row in visibleResultRows"
:key="row.category.id"
class="grid gap-4 px-5 py-4 2xl:grid-cols-[minmax(0,1fr)_420px_auto] 2xl:items-center"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="truncate font-semibold text-slate-900">{{ row.category.name }}</p>
<span v-if="row.existing" class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">
<CheckCircle2 class="h-3.5 w-3.5" />
gesetzt
</span>
<span v-if="row.hasPendingReviews" class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
{{ row.openReviews }} Reviews offen
</span>
<span v-if="row.isEmpty" class="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700">
keine Kandidaten
</span>
</div>
<p class="mt-1 text-sm text-slate-500">
{{ row.category.groupName }} · {{ row.candidates.length }} Kandidaten · {{ row.approvedClips }} Clips freigegeben
</p>
<p v-if="row.existing" class="mt-2 inline-flex items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">
<Award class="h-4 w-4" />
Aktuell: {{ row.existing.candidateDisplayName }} · {{ row.existing.candidatePlatform }}
</p>
</div>
<select
v-model="winnerSelections[row.category.id]"
:disabled="row.isEmpty"
class="h-12 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
>
<option value="">Bitte Gewinner waehlen</option>
<option v-for="candidate in row.candidates" :key="candidate.id" :value="`${candidate.id}`">
{{ candidate.displayName }} · {{ candidate.channelSlug }} · {{ candidate.platform }}
</option>
</select>
<div class="flex flex-wrap justify-end gap-2">
<Button :disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id]" @click="saveWinner(row.category.id)">
{{ savingResultForCategory === row.category.id ? 'Speichert ...' : row.existing ? 'Aktualisieren' : 'Setzen' }}
</Button>
<Button
v-if="row.existing"
variant="secondary"
class="gap-1.5"
:disabled="deletingResultId === row.existing.id"
@click="clearWinner(row.existing.id)"
>
<Trash2 class="h-4 w-4" />
{{ deletingResultId === row.existing.id ? 'Entfernt ...' : 'Entfernen' }}
</Button>
</div>
</article>
</div>
<div v-else class="p-8 text-center">
<Trophy class="mx-auto h-8 w-8 text-violet-300" />
<p class="mt-3 font-semibold text-slate-900">Keine Kategorien fuer diese Filter.</p>
<p class="mt-1 text-sm text-slate-500">Passe Suche oder Statusfilter an.</p>
</div>
</Card>
</div>
</template>