Refine admin panel workflows

This commit is contained in:
AzuTear
2026-06-18 14:29:40 +02:00
parent f4512eba2e
commit d015fcbe6f
14 changed files with 481 additions and 598 deletions
+1 -1
View File
@@ -758,7 +758,7 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
new[] new[]
{ {
new AdminMetricDto("Nominierungen", nominationCount, "+12.4% vs. gestern"), new AdminMetricDto("Nominierungen", nominationCount, "+12.4% vs. gestern"),
new AdminMetricDto("Votes", voteCount, "+8.7% vs. gestern"), new AdminMetricDto("Stimmen", voteCount, "+8.7% vs. gestern"),
new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"), new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"),
new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"), new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"),
}, },
+10
View File
@@ -0,0 +1,10 @@
import type { AdminMetric } from '../types/awards'
export function getMetricValue(metrics: AdminMetric[], labels: string[]) {
const normalizedLabels = labels.map((label) => label.toLowerCase())
return metrics.find((metric) => normalizedLabels.includes(metric.label.toLowerCase()))?.value ?? 0
}
export function getVoteMetricValue(metrics: AdminMetric[]) {
return getMetricValue(metrics, ['Stimmen', 'Votes'])
}
+16 -2
View File
@@ -216,6 +216,20 @@ const emptyAdminSeasonDetail: AdminSeasonDetailResponse = {
clipSubmissions: [], clipSubmissions: [],
} }
/**
* Guarantee the array fields exist even if a (possibly older) backend omits them,
* so views can safely read `.length`/`.filter` without crashing the render.
*/
function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminSeasonDetailResponse {
return {
...detail,
categories: detail.categories ?? [],
candidates: detail.candidates ?? [],
pendingNominations: detail.pendingNominations ?? [],
clipSubmissions: detail.clipSubmissions ?? [],
}
}
export const useAwardsStore = defineStore('awards', { export const useAwardsStore = defineStore('awards', {
state: () => ({ state: () => ({
overview: fallbackOverview as OverviewResponse, overview: fallbackOverview as OverviewResponse,
@@ -259,7 +273,7 @@ export const useAwardsStore = defineStore('awards', {
} }
if (this.adminSelectedSeasonId) { if (this.adminSelectedSeasonId) {
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSelectedSeasonId) this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(this.adminSelectedSeasonId))
} }
this.apiMode = 'api' this.apiMode = 'api'
} catch { } catch {
@@ -272,7 +286,7 @@ export const useAwardsStore = defineStore('awards', {
async loadAdminSeasonDetail(seasonId: number) { async loadAdminSeasonDetail(seasonId: number) {
try { try {
this.adminSelectedSeasonId = seasonId this.adminSelectedSeasonId = seasonId
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId) this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(seasonId))
this.apiMode = 'api' this.apiMode = 'api'
} catch { } catch {
this.adminSeasonDetail = emptyAdminSeasonDetail this.adminSeasonDetail = emptyAdminSeasonDetail
@@ -5,11 +5,12 @@ import { BarChart3, Clock3, Sparkles, Tags, Users, Vote } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore() const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail) const seasonDetail = computed(() => store.adminSeasonDetail)
const totalVotes = computed(() => store.admin.metrics.find((metric) => metric.label === 'Stimmen')?.value ?? 0) const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0) const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
const maxVotes = computed(() => Math.max(...store.admin.topCategories.map((category) => category.votes), 1)) const maxVotes = computed(() => Math.max(...store.admin.topCategories.map((category) => category.votes), 1))
const categoryHealth = computed(() => const categoryHealth = computed(() =>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import Select from 'primevue/select' import Select from 'primevue/select'
import { ChevronLeft, ChevronRight, Pencil, Search, Trash2, TriangleAlert, UserPlus, Users, X } from '@lucide/vue' import { ChevronLeft, ChevronRight, Layers3, Pencil, Search, Trash2, TriangleAlert, UserPlus, Users, X } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
@@ -33,6 +33,23 @@ const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value:
const categoryLabelMap = computed(() => const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((c) => [c.id, `${c.groupName} · ${c.name}`])), 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 filteredCandidates = computed(() => {
const query = search.value.trim().toLowerCase() const query = search.value.trim().toLowerCase()
@@ -151,6 +168,36 @@ async function confirmDelete() {
<AdminSeasonToolbar /> <AdminSeasonToolbar />
<section class="grid gap-4 md:grid-cols-3">
<Card 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">Kandidaten</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ seasonDetail.candidates.length }}</strong>
</div>
<Users class="h-6 w-6 text-violet-500" />
</div>
</Card>
<Card 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">Kategorien</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ seasonDetail.categories.length }}</strong>
</div>
<UserPlus class="h-6 w-6 text-violet-500" />
</div>
</Card>
<Card class="p-5" :class="duplicateCandidateCount > 0 ? 'border-amber-200 bg-amber-50/60' : ''">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="duplicateCandidateCount > 0 ? 'text-amber-600' : 'text-violet-500'">Mögliche Duplikate</p>
<strong class="mt-3 block text-3xl" :class="duplicateCandidateCount > 0 ? 'text-amber-700' : 'text-violet-900'">{{ duplicateCandidateCount }}</strong>
</div>
<Layers3 class="h-6 w-6" :class="duplicateCandidateCount > 0 ? 'text-amber-500' : 'text-violet-500'" />
</div>
</Card>
</section>
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<!-- Toolbar --> <!-- Toolbar -->
<div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center"> <div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
@@ -203,6 +250,12 @@ async function confirmDelete() {
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p> <p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</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> </div>
<div class="min-w-0"> <div class="min-w-0">
+78 -13
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ExternalLink, Film, PlayCircle, Search, Tags, Trash2, TriangleAlert, Users } from '@lucide/vue' import { ExternalLink, Film, Layers3, PlayCircle, Search, Trash2, TriangleAlert, Users } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
@@ -12,6 +12,9 @@ import type { AdminClipSubmissionItem } from '../../types/awards'
const store = useAwardsStore() const store = useAwardsStore()
const query = ref('') 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 deleting = ref(false)
const adminMessage = ref('') const adminMessage = ref('')
const adminError = ref('') const adminError = ref('')
@@ -19,22 +22,60 @@ const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail) const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId) const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
const categories = computed(() => seasonDetail.value.categories ?? [])
const categoryName = computed(() => const categoryName = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])), Object.fromEntries(categories.value.map((category) => [category.id, category.name])),
) )
const clips = computed(() => { const clips = computed(() => {
const search = query.value.trim().toLowerCase() const search = query.value.trim().toLowerCase()
if (!search) return seasonDetail.value.clipSubmissions return submissions.value.filter((clip) =>
return seasonDetail.value.clipSubmissions.filter((clip) => (statusFilter.value === 'all' || clip.status === statusFilter.value || (statusFilter.value === 'reviewed' && clip.status !== 'pending')) &&
[clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search), (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(() => [ const stats = computed(() => [
{ label: 'Einreichungen', value: seasonDetail.value.clipSubmissions.length, icon: Film }, { label: 'Einreichungen', value: submissions.value.length, icon: Film },
{ label: 'Offen', value: seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length, icon: PlayCircle }, { label: 'Offen', value: submissions.value.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ label: 'Clip-Kategorien', value: seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length, icon: Tags }, { 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) { function platformClass(platform: string) {
@@ -63,8 +104,8 @@ async function confirmDelete() {
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Clips" eyebrow="Clips"
title="Clip-Einreichungen moderieren" title="Clip-Einreichungen triagieren"
description="Alle von der Community eingereichten Clips laufen hier auf. Sieh sie dir an, prüfe die Links und entferne Spam oder Duplikate." 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" :icon="Film"
/> />
@@ -85,15 +126,35 @@ async function confirmDelete() {
</section> </section>
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5"> <div class="space-y-4 border-b border-violet-100 p-5">
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_220px]">
<label class="relative block"> <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" /> <Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input <input
v-model="query" 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" 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="Titel, Creator, Plattform oder User suchen …" placeholder="Titel, Creator, Plattform, URL oder User suchen …"
/> />
</label> </label>
<select v-model="platformFilter" class="h-12 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">
<option v-for="filter in platformFilters" :key="filter.key" :value="filter.key">{{ filter.label }} · {{ filter.count }}</option>
</select>
<select v-model="categoryFilter" class="h-12 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">
<option v-for="filter in categoryFilters" :key="filter.id" :value="filter.id">{{ filter.label }} · {{ filter.count }}</option>
</select>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="filter in statusFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="statusFilter = filter.key"
>
{{ filter.label }} · {{ filter.count }}
</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="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
@@ -111,8 +172,12 @@ async function confirmDelete() {
<span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span> <span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span>
· von {{ clip.submittedByTwitchId }} · von {{ clip.submittedByTwitchId }}
</p> </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>
</div> </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 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>
<a <a
:href="clip.clipUrl" :href="clip.clipUrl"
target="_blank" target="_blank"
@@ -133,7 +198,7 @@ async function confirmDelete() {
<div v-if="clips.length === 0" class="px-5 py-12 text-center"> <div v-if="clips.length === 0" class="px-5 py-12 text-center">
<Users class="mx-auto h-6 w-6 text-violet-300" /> <Users class="mx-auto h-6 w-6 text-violet-300" />
<p class="mt-2 text-sm text-slate-500"> <p class="mt-2 text-sm text-slate-500">
{{ seasonDetail.clipSubmissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }} {{ submissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p> </p>
</div> </div>
</div> </div>
@@ -4,7 +4,9 @@ import { ArrowDownRight, ArrowUpRight, BarChart3, Clock3, LayoutDashboard, Shiel
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore() const store = useAwardsStore()
@@ -60,7 +62,7 @@ const yearTotals = computed(() => [
}, },
{ {
label: 'Stimmen gesamt', label: 'Stimmen gesamt',
value: metrics.value.find((metric) => metric.label === 'Stimmen')?.value ?? 0, value: getVoteMetricValue(metrics.value),
note: 'alle abgegebenen Votes', note: 'alle abgegebenen Votes',
icon: BarChart3, icon: BarChart3,
}, },
@@ -166,6 +168,8 @@ const operationChecks = computed(() => {
:icon="LayoutDashboard" :icon="LayoutDashboard"
/> />
<AdminSeasonToolbar />
<section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]"> <section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card class="overflow-hidden"> <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="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/60 p-6">
+4 -3
View File
@@ -17,6 +17,7 @@ import {
} from '@lucide/vue' } from '@lucide/vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
@@ -30,7 +31,7 @@ const navGroups = [
items: [ items: [
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, badge: () => null }, { 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: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Voting', to: '/admin/voting', description: 'Stimmen und Readiness', icon: Vote, badge: () => `${store.admin.metrics.find((metric) => metric.label === 'Stimmen')?.value ?? 0}` }, { label: 'Voting', to: '/admin/voting', description: 'Readiness und Sperren', icon: Vote, badge: () => `${getVoteMetricValue(store.admin.metrics)}` },
], ],
}, },
{ {
@@ -46,8 +47,8 @@ const navGroups = [
label: 'Kontrolle', label: 'Kontrolle',
items: [ items: [
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Fälle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` }, { label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Fälle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Risiko & Audit', to: '/admin/risk', description: 'Flags prüfen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` }, { label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
{ label: 'User & Logs', to: '/admin/users-logs', description: 'User-Spuren und Aktionen', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` }, { label: 'Team-Audit', to: '/admin/users-logs', description: 'Admin-Spuren', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` },
], ],
}, },
{ {
+11 -3
View File
@@ -62,6 +62,11 @@ const selectedCandidateCollision = computed(() => {
(candidate.displayName.trim().toLowerCase() === normalizedName || (!!normalizedSlug && candidate.channelSlug.trim().toLowerCase() === normalizedSlug)), (candidate.displayName.trim().toLowerCase() === normalizedName || (!!normalizedSlug && candidate.channelSlug.trim().toLowerCase() === normalizedSlug)),
) ?? null ) ?? 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( watch(
seasonDetail, seasonDetail,
@@ -136,7 +141,7 @@ function setPlatform(platform: string) {
<AdminPageHeader <AdminPageHeader
eyebrow="Reviews" eyebrow="Reviews"
title="Freitext-Nominierungen sichten" title="Freitext-Nominierungen sichten"
description="Alle uneindeutigen oder noch nicht gemappten Nominierungen laufen hier zusammen. Suche nach User, Kategorie oder Kandidat und entscheide dann direkt im Kontext." 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."
:icon="Sparkles" :icon="Sparkles"
/> />
@@ -294,7 +299,10 @@ function setPlatform(platform: string) {
</button> </button>
</div> </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"> <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. 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> </p>
</div> </div>
@@ -303,7 +311,7 @@ function setPlatform(platform: string) {
<Trash2 class="mr-2 h-4 w-4" /> <Trash2 class="mr-2 h-4 w-4" />
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Verwerfen' }} {{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Verwerfen' }}
</Button> </Button>
<Button :disabled="reviewSaving === selectedNomination.id" @click="approveNomination(selectedNomination.id)"> <Button :disabled="reviewSaving === selectedNomination.id || !canApproveSelected" @click="approveNomination(selectedNomination.id)">
<CheckCircle2 class="mr-2 h-4 w-4" /> <CheckCircle2 class="mr-2 h-4 w-4" />
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }} {{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
</Button> </Button>
+16 -55
View File
@@ -12,11 +12,9 @@ const riskSaving = ref<number | null>(null)
const adminMessage = ref('') const adminMessage = ref('')
const adminError = ref('') const adminError = ref('')
const riskFilter = ref('') const riskFilter = ref('')
const auditFilter = ref('')
const severityFilter = ref<'all' | 'high' | 'medium' | 'low'>('all') const severityFilter = ref<'all' | 'high' | 'medium' | 'low'>('all')
const riskFlags = computed(() => store.admin.riskFlags) const riskFlags = computed(() => store.admin.riskFlags)
const auditEntries = computed(() => store.admin.auditEntries)
const filteredRiskFlags = computed(() => { const filteredRiskFlags = computed(() => {
const query = riskFilter.value.trim().toLowerCase() const query = riskFilter.value.trim().toLowerCase()
return riskFlags.value.filter((flag) => return riskFlags.value.filter((flag) =>
@@ -27,16 +25,6 @@ const filteredRiskFlags = computed(() => {
.includes(query)), .includes(query)),
) )
}) })
const filteredAuditEntries = computed(() => {
const query = auditFilter.value.trim().toLowerCase()
if (!query) return auditEntries.value
return auditEntries.value.filter((entry) =>
[entry.summary, entry.adminTwitchUserId, entry.actionType, entry.entityType, entry.entityId]
.join(' ')
.toLowerCase()
.includes(query),
)
})
const riskStats = computed(() => [ const riskStats = computed(() => [
{ label: 'Offen', value: riskFlags.value.length }, { label: 'Offen', value: riskFlags.value.length },
{ label: 'High', value: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length }, { label: 'High', value: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length },
@@ -68,13 +56,13 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Risiko & Audit" eyebrow="Risiko"
title="Auffällige Muster und Admin-Aktionen verfolgen" title="Auffällige Muster entscheiden"
description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgeführte Eingriffe deutlich schneller." 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."
:icon="ShieldAlert" :icon="ShieldAlert"
/> />
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]"> <div class="grid gap-6 xl:grid-cols-[0.82fr_1.18fr]">
<Card class="p-7"> <Card class="p-7">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
@@ -167,48 +155,21 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
</Card> </Card>
<Card class="p-7"> <Card class="p-7">
<div class="flex items-center justify-between gap-4"> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review-Protokoll</p>
<div> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Risk Playbook</h2>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit-Protokoll</h2> <div class="mt-6 space-y-3">
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen für Kategorie-, Kandidaten- und Review-Änderungen.</p> <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>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500"> <div class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
{{ filteredAuditEntries.length }} / {{ auditEntries.length }} Einträge <p class="font-semibold text-slate-900">2. Entscheidung dokumentieren</p>
</span> <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>
<div class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<div class="mt-6"> <p class="font-semibold text-slate-900">3. Audit separat lesen</p>
<input <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>
v-model="auditFilter"
type="text"
class="w-full rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Audit-Einträge nach Aktion, Admin oder Objekt filtern"
/>
</div> </div>
<div class="mt-6 space-y-4">
<div
v-for="entry in filteredAuditEntries"
:key="entry.id"
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="font-semibold text-slate-800">{{ entry.summary }}</p>
<p class="mt-1 text-sm text-slate-500">
{{ entry.adminTwitchUserId }} · {{ entry.actionType }} · {{ entry.entityType }} {{ entry.entityId }}
</p>
</div>
<p class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</p>
</div>
</div>
<p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine Audit-Einträge vorhanden.
</p>
<p v-else-if="filteredAuditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Audit-Einträge passen zum aktuellen Filter.
</p>
</div> </div>
</Card> </Card>
</div> </div>
+113 -316
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { CalendarCog, Layers3, PlusCircle, Search } from '@lucide/vue' import { CalendarCog, CheckCircle2, Clock3, Layers3, ShieldCheck, Tags, Users } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
@@ -9,89 +10,55 @@ import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore() const store = useAwardsStore()
const seasonSaving = ref(false) const saving = ref(false)
const categorySaving = ref<number | 'new' | null>(null)
const adminMessage = ref('') const adminMessage = ref('')
const adminError = ref('') const adminError = ref('')
const seasonForm = reactive({ const form = reactive({
currentPhase: '', currentPhase: '',
isCurrent: false, isCurrent: false,
}) })
const newCategoryForm = reactive({
groupName: '',
name: '',
slug: '',
description: '',
sortOrder: 1,
maxNomineesPerUser: 3,
})
const editForms = reactive<Record<number, {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}>>({})
const seasonDetail = computed(() => store.adminSeasonDetail) const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId) const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const categoryFilter = ref('') const selectedSeason = computed(() =>
const selectedCategoryId = ref<number | null>(null) store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
const categoryStats = computed(() => [
{ label: 'Kategorien', value: seasonDetail.value.categories.length },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length },
{ label: 'Reviews offen', value: seasonDetail.value.pendingNominations.length },
])
const filteredCategories = computed(() => {
const query = categoryFilter.value.trim().toLowerCase()
if (!query) return seasonDetail.value.categories
return seasonDetail.value.categories.filter((category) =>
[category.groupName, category.name, category.slug, category.description]
.join(' ')
.toLowerCase()
.includes(query),
) )
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 selectedCategory = computed(() => const phasePresets = ['Vorbereitung', 'Nominierung', 'Community Voting', 'Auswertung', 'Award Show', 'Archiviert']
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
)
watch( watch(
seasonDetail, seasonDetail,
(detail) => { (detail) => {
seasonForm.currentPhase = detail.currentPhase form.currentPhase = detail.currentPhase
seasonForm.isCurrent = detail.isCurrent form.isCurrent = detail.isCurrent
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
}
}, },
{ immediate: true }, { immediate: true },
) )
@@ -99,60 +66,20 @@ watch(
async function saveSeason() { async function saveSeason() {
if (!selectedSeasonId.value) return if (!selectedSeasonId.value) return
seasonSaving.value = true saving.value = true
adminMessage.value = '' adminMessage.value = ''
adminError.value = '' adminError.value = ''
try { try {
await store.updateAdminSeason(selectedSeasonId.value, { await store.updateAdminSeason(selectedSeasonId.value, {
currentPhase: seasonForm.currentPhase, currentPhase: form.currentPhase,
isCurrent: seasonForm.isCurrent, isCurrent: form.isCurrent,
}) })
adminMessage.value = 'Jahres-Einstellungen gespeichert.' adminMessage.value = 'Jahresstatus gespeichert.'
} catch (error) { } catch (error) {
adminError.value = error instanceof Error ? error.message : 'Jahr konnte nicht gespeichert werden.' adminError.value = error instanceof Error ? error.message : 'Jahr konnte nicht gespeichert werden.'
} finally { } finally {
seasonSaving.value = false saving.value = false
}
}
async function saveCategory(categoryId: number) {
if (!selectedSeasonId.value) return
categorySaving.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 {
categorySaving.value = null
}
}
async function createCategory() {
if (!selectedSeasonId.value) return
categorySaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
adminMessage.value = 'Neue 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 {
categorySaving.value = null
} }
} }
</script> </script>
@@ -161,273 +88,143 @@ async function createCategory() {
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Jahre" eyebrow="Jahre"
title="Jahr und Kategorien verwalten" title="Award-Jahr steuern"
description="Hier steuerst du die aktive Phase, legst neue Kategorien an und pflegst bestehende Gruppen, Limits und Beschreibungen." 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."
:icon="CalendarCog" :icon="CalendarCog"
/> />
<AdminSeasonToolbar /> <AdminSeasonToolbar />
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]"> <section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<Card class="overflow-hidden"> <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="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 class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Jahresstatus</p> <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">Award-Jahr steuern</h2> <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"> <p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
Hier legst du fest, in welcher Phase das Jahr ist und ob genau dieses Jahr öffentlich r Community, Voting und Archiv sichtbar ist. Der Status steuert die Admin-Orientierung und den Public-Kontext. Inhaltliche Pflege passiert über Kategorien, Kandidaten, Reviews und Clips.
</p> </p>
</div> </div>
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="seasonForm.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'"> <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'">
{{ seasonForm.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }} {{ form.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
</div> </div>
</div> </div>
</div> </div>
<div class="space-y-5 p-6"> <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>
<label class="block space-y-2"> <label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aktuelle Phase</span> <span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aktuelle Phase</span>
<input <input
v-model="seasonForm.currentPhase" v-model="form.currentPhase"
type="text" 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" 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, Nominierung, Archiviert" placeholder="z.B. Community Voting"
/> />
<span class="block text-xs leading-5 text-slate-500">
Diese Phase wird als Orientierung für Team und später auch für Public-Kommunikation genutzt.
</span>
</label> </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"> <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="seasonForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" /> <input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span> <span>
<span class="block font-semibold text-slate-800">Dieses Award-Jahr öffentlich schalten</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"> <span class="mt-1 block text-sm leading-6 text-slate-500">
Wenn aktiv, gilt dieses Jahr als aktueller Public-Kontext. Nur ein Award-Jahr sollte gleichzeitig öffentlich sein. Nur ein Award-Jahr sollte gleichzeitig als Public-Kontext aktiv sein.
</span> </span>
</span> </span>
</label> </label>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-2xl border border-violet-50 bg-white/80 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Jahr</p>
<strong class="mt-2 block text-xl text-violet-800">{{ seasonDetail.year || '-' }}</strong>
</div>
<div class="rounded-2xl border border-violet-50 bg-white/80 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Kategorien</p>
<strong class="mt-2 block text-xl text-violet-800">{{ seasonDetail.categories.length }}</strong>
</div>
<div class="rounded-2xl border border-violet-50 bg-white/80 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Kandidaten</p>
<strong class="mt-2 block text-xl text-violet-800">{{ seasonDetail.candidates.length }}</strong>
</div>
</div>
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm leading-6 text-slate-500">
Speichert Phase und Public-Status für <strong class="text-slate-700">{{ seasonDetail.name }}</strong>.
</p>
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
{{ seasonSaving ? 'Speichert ...' : 'Jahresstatus speichern' }}
</Button>
</div>
<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="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> <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> </div>
</Card> </Card>
<Card class="overflow-hidden"> <Card class="p-6">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-[#f7f2ff] to-[#f7eef8] p-6">
<div class="flex items-start gap-4"> <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"> <div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<PlusCircle class="h-5 w-5" /> <Layers3 class="h-5 w-5" />
</div> </div>
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neue Kategorie</p> <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">Kategorie planen</h2> <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"> <p class="mt-2 text-sm leading-6 text-slate-500">
Lege zuerst Gruppe, Namen und Limit fest. Slug und Sortierung bestimmen später URL, Anzeige und Reihenfolge im Voting. Schneller Überblick, ob das gewählte Jahr bereit für die nächste Award-Phase ist.
</p> </p>
</div> </div>
</div> </div>
</div>
<div class="space-y-4 p-6"> <div class="mt-6 space-y-3">
<div class="grid gap-4 sm:grid-cols-2"> <RouterLink
<label class="space-y-2"> v-for="item in seasonHealth"
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span> :key="item.label"
<input v-model="newCategoryForm.groupName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. Hauptpreise" /> :to="item.to"
</label> 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"
<label class="space-y-2"> >
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span> <div class="flex min-w-0 items-center gap-3">
<input v-model="newCategoryForm.name" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. VTuber des Jahres" /> <div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
</label> <component :is="item.icon" class="h-5 w-5" />
</div> </div>
<div class="min-w-0">
<label class="space-y-2"> <p class="font-semibold text-slate-900">{{ item.label }}</p>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span> <p class="truncate text-sm text-slate-500">{{ item.note }}</p>
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kurz erklären, wofür diese Kategorie steht." />
</label>
<div class="grid gap-4 sm:grid-cols-3">
<label class="space-y-2 sm:col-span-1">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
<input v-model="newCategoryForm.slug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="vtuber-des-jahres" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input v-model="newCategoryForm.sortOrder" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="1" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierungslimit</span>
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="3" />
</label>
</div> </div>
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm leading-6 text-slate-500">
Neue Kategorien sind sofort Teil des gewählten Award-Jahres und können danach unten weiter bearbeitet werden.
</p>
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
</Button>
</div> </div>
<strong class="text-xl text-violet-800">{{ item.value }}</strong>
</RouterLink>
</div> </div>
</Card> </Card>
</div> </section>
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-white/75 p-6"> <div class="border-b border-violet-100 p-5">
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between"> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Alle Jahre</p>
<div> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Season-Liste</h2>
<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">Kategorien dieses Jahres</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Prüfe Struktur, Slug, Limit und Kandidatenzahl pro Kategorie. Erst filtern, dann gezielt bearbeiten.
</p>
</div> </div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[380px]"> <div class="divide-y divide-violet-50">
<div v-for="stat in categoryStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="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="categoryFilter"
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 Name, Gruppe oder Slug suchen"
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
{{ filteredCategories.length }} / {{ seasonDetail.categories.length }} sichtbar
</div>
</div>
</div>
<div class="grid gap-5 p-6 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 <button
v-for="category in filteredCategories" v-for="season in store.adminSeasons"
:key="category.id" :key="season.id"
type="button" type="button"
class="w-full rounded-2xl border p-3 text-left transition" 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="selectedCategory?.id === category.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'" :class="selectedSeason?.id === season.id ? 'bg-violet-50/80' : ''"
@click="selectedCategoryId = category.id" @click="store.loadAdminSeasonDetail(season.id)"
> >
<div class="flex items-start justify-between gap-3"> <strong class="text-violet-800">{{ season.year }}</strong>
<div class="min-w-0"> <span class="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <span class="block truncate font-semibold text-slate-900">{{ season.name }}</span>
<span class="inline-flex items-center gap-1.5 rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700"> <span class="mt-1 block truncate text-sm text-slate-500">{{ season.categoryCount }} Kategorien</span>
<Layers3 class="h-3 w-3" />
{{ category.groupName }}
</span> </span>
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-700"> <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">
{{ category.candidateCount }} Kandidaten <Clock3 class="h-3.5 w-3.5 text-violet-500" />
{{ season.currentPhase }}
</span> </span>
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600"> <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'">
Limit {{ category.maxNomineesPerUser }} <CheckCircle2 class="h-3.5 w-3.5" />
{{ season.isCurrent ? 'Public' : 'Intern' }}
</span> </span>
</div>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ category.name }}</h3>
<p class="mt-1 line-clamp-2 text-sm leading-5 text-slate-500">{{ category.description }}</p>
</div>
<span class="shrink-0 rounded-xl border border-violet-100 bg-white px-2.5 py-1 text-xs font-semibold text-violet-800">
#{{ category.sortOrder }}
</span>
</div>
</button> </button>
<p v-if="store.adminSeasons.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
<p v-if="filteredCategories.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> Noch keine Award-Jahre aus der API geladen.
Keine Kategorien passen zum aktuellen Filter.
</p> </p>
</div> </div>
<div v-if="selectedCategory" 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">Kategorie bearbeiten</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h3>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ selectedCategory.description }}</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700">
{{ selectedCategory.candidateCount }} Kandidaten
</span>
<span class="rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-600">
Limit {{ selectedCategory.maxNomineesPerUser }}
</span>
</div>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
<input v-model="editForms[selectedCategory.id].groupName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Gruppe" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input v-model="editForms[selectedCategory.id].name" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Name" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
<input v-model="editForms[selectedCategory.id].slug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="slug" />
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input v-model="editForms[selectedCategory.id].sortOrder" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="1" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
<input v-model="editForms[selectedCategory.id].maxNomineesPerUser" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="3" />
</label>
</div>
</div>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea
v-model="editForms[selectedCategory.id].description"
class="min-h-24 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Beschreibung"
/>
</label>
<div class="mt-4 flex justify-end">
<Button :disabled="categorySaving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
{{ categorySaving === selectedCategory.id ? 'Speichert ...' : 'Kategorie speichern' }}
</Button>
</div>
</div>
</div>
</Card> </Card>
</div> </div>
</template> </template>
+70 -101
View File
@@ -1,23 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed } from 'vue'
import { CheckCircle2, Database, Settings, ShieldCheck } from '@lucide/vue' import { CheckCircle2, Database, Settings, ShieldCheck, Tags, Vote } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore() const store = useAwardsStore()
const saving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const seasonDetail = computed(() => store.adminSeasonDetail) const seasonDetail = computed(() => store.adminSeasonDetail)
const form = reactive({ const hasVotingPhase = computed(() => seasonDetail.value.currentPhase.toLowerCase().includes('voting'))
currentPhase: '', const categoriesWithoutCandidates = computed(() =>
isCurrent: false, 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 checks = computed(() => [ const checks = computed(() => [
{ {
@@ -25,137 +24,107 @@ const checks = computed(() => [
value: store.apiMode === 'api', value: store.apiMode === 'api',
note: store.apiMode === 'api' ? 'Admin-Daten kommen aus der API.' : 'Fallback-Daten aktiv oder API nicht erreichbar.', note: store.apiMode === 'api' ? 'Admin-Daten kommen aus der API.' : 'Fallback-Daten aktiv oder API nicht erreichbar.',
icon: Database, icon: Database,
to: null,
}, },
{ {
label: 'Public-Jahr gesetzt', label: 'Public-Jahr gesetzt',
value: seasonDetail.value.isCurrent, value: seasonDetail.value.isCurrent,
note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist öffentlich markiert.' : 'Dieses Jahr ist aktuell intern.', note: seasonDetail.value.isCurrent ? `${seasonDetail.value.year} ist öffentlich markiert.` : 'Das gewählte Jahr ist aktuell intern.',
icon: CheckCircle2, icon: CheckCircle2,
to: '/admin/years',
}, },
{ {
label: 'Review-Schutz aktiv', label: 'Voting-Basis vollständig',
value: store.admin.riskFlags.length >= 0, value: categoriesWithoutCandidates.value.length === 0 && seasonDetail.value.categories.length > 0,
note: `${store.admin.riskFlags.length} Risikohinweise im Admin-Kontext.`, 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, icon: ShieldCheck,
to: '/admin/risk',
}, },
]) ])
const featureGates = computed(() => [ const gates = computed(() => [
{ {
label: 'Nominierungen', label: 'Nominierungen',
state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'), state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'),
note: 'Public-Nominierungen sollten nur im passenden Zeitraum aktiv sein.', note: 'Aktiv, wenn die Season-Phase auf Nominierung steht.',
to: '/admin/years',
}, },
{ {
label: 'Voting', label: 'Voting',
state: seasonDetail.value.currentPhase.toLowerCase().includes('voting'), state: hasVotingPhase.value && categoriesWithoutCandidates.value.length === 0,
note: 'Voting sollte erst aktiv sein, wenn Kategorien und Kandidaten gepflegt sind.', note: hasVotingPhase.value ? 'Phase ist Voting; Kategorie-Readiness entscheidet.' : 'Phase ist nicht Voting.',
}, to: '/admin/voting',
{
label: 'Community-only Ergebnis',
state: true,
note: 'Aktuell als Community-basierte Auswertung geplant.',
}, },
{ {
label: 'Clip-Moderation', label: 'Clip-Moderation',
state: true, state: pendingClips.value > 0,
note: 'Clip-Einreichungen laufen in den Clips-Bereich und können dort moderiert werden.', 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',
}, },
]) ])
watch(
seasonDetail,
(detail) => {
form.currentPhase = detail.currentPhase
form.isCurrent = detail.isCurrent
},
{ immediate: true },
)
async function saveSettings() {
if (!selectedSeasonId.value) return
saving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminSeason(selectedSeasonId.value, {
currentPhase: form.currentPhase,
isCurrent: form.isCurrent,
})
adminMessage.value = 'Einstellungen gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Einstellungen konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Einstellungen" eyebrow="Einstellungen"
title="Public-Status und Systemchecks" title="Systemchecks ohne Doppelpflege"
description="Hier liegen bewusst nur Einstellungen, die das aktuelle Award-Jahr oder die Admin-Betriebsbereitschaft betreffen. Kategorie-Inhalte bleiben in Kategorien/Jahre." 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."
:icon="Settings" :icon="Settings"
/> />
<AdminSeasonToolbar /> <AdminSeasonToolbar />
<section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]"> <section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card class="p-6"> <RouterLink
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Award-Jahr</p> v-for="check in checks"
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Sichtbarkeit steuern</h2> :key="check.label"
<p class="mt-2 text-sm leading-6 text-slate-500"> :to="check.to ?? '/admin/settings'"
Diese Einstellungen werden gespeichert und beeinflussen, welches Jahr als aktueller Public-Kontext gilt. 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"
</p> :class="check.value ? 'border-emerald-100' : 'border-amber-100'"
>
<div class="mt-6 space-y-5"> <div class="flex items-start justify-between gap-4">
<label class="block space-y-2"> <div>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Phase</span> <p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="check.value ? 'text-emerald-600' : 'text-amber-600'">{{ check.label }}</p>
<input v-model="form.currentPhase" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Community Voting" /> <p class="mt-3 text-sm leading-6 text-slate-600">{{ check.note }}</p>
</label>
<label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span>
<span class="block font-semibold text-slate-900">Dieses Award-Jahr öffentlich markieren</span>
<span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext für Community, Voting und später Archiv.</span>
</span>
</label>
</div> </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'">
<p v-if="adminMessage" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="mt-6 flex justify-end">
<Button :disabled="saving || !selectedSeasonId" @click="saveSettings">{{ saving ? 'Speichert ...' : 'Einstellungen speichern' }}</Button>
</div>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Checks</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Betriebsstatus</h2>
<div class="mt-6 space-y-3">
<div v-for="check in checks" :key="check.label" class="flex gap-4 rounded-[22px] border border-violet-100 bg-white/90 p-4">
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl" :class="check.value ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
<component :is="check.icon" class="h-5 w-5" /> <component :is="check.icon" class="h-5 w-5" />
</div> </div>
<div>
<p class="font-semibold text-slate-900">{{ check.label }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ check.note }}</p>
</div> </div>
</div> </RouterLink>
</div>
</Card>
</section> </section>
<Card class="p-6"> <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> <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 aktuell aktiv?</h2> <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>
<div class="mt-5 grid gap-3 md:grid-cols-2"> <div class="mt-5 grid gap-3 md:grid-cols-2">
<div <RouterLink
v-for="gate in featureGates" v-for="gate in gates"
:key="gate.label" :key="gate.label"
class="rounded-[22px] border p-4" :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'" :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 class="flex items-start justify-between gap-3">
@@ -167,7 +136,7 @@ async function saveSettings() {
{{ gate.state ? 'aktiv' : 'inaktiv' }} {{ gate.state ? 'aktiv' : 'inaktiv' }}
</span> </span>
</div> </div>
</div> </RouterLink>
</div> </div>
</Card> </Card>
</div> </div>
+22 -31
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { Search, ShieldAlert, UserCog } from '@lucide/vue' import { FileClock, Search, UserCog } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
@@ -9,7 +9,6 @@ import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore() const store = useAwardsStore()
const query = ref('') const query = ref('')
const auditEntries = computed(() => store.admin.auditEntries) const auditEntries = computed(() => store.admin.auditEntries)
const riskFlags = computed(() => store.admin.riskFlags)
const filteredAuditEntries = computed(() => { const filteredAuditEntries = computed(() => {
const search = query.value.trim().toLowerCase() const search = query.value.trim().toLowerCase()
if (!search) return auditEntries.value if (!search) return auditEntries.value
@@ -20,20 +19,6 @@ const filteredAuditEntries = computed(() => {
.includes(search), .includes(search),
) )
}) })
const filteredRiskUsers = computed(() => {
const search = query.value.trim().toLowerCase()
const users = riskFlags.value.map((flag) => ({
id: flag.id,
twitchUserId: flag.twitchUserId ?? 'unbekannt',
source: flag.source,
type: flag.type,
severity: flag.severity,
ip: flag.createdFromIp,
createdAt: flag.createdAt,
}))
if (!search) return users
return users.filter((user) => [user.twitchUserId, user.source, user.type, user.ip].join(' ').toLowerCase().includes(search))
})
const adminCounts = computed(() => { const adminCounts = computed(() => {
const counts = new Map<string, number>() const counts = new Map<string, number>()
for (const entry of auditEntries.value) counts.set(entry.adminTwitchUserId, (counts.get(entry.adminTwitchUserId) ?? 0) + 1) for (const entry of auditEntries.value) counts.set(entry.adminTwitchUserId, (counts.get(entry.adminTwitchUserId) ?? 0) + 1)
@@ -42,16 +27,23 @@ const adminCounts = computed(() => {
const logStats = computed(() => [ const logStats = computed(() => [
{ label: 'Audit-Einträge', value: auditEntries.value.length }, { label: 'Audit-Einträge', value: auditEntries.value.length },
{ label: 'Admins aktiv', value: adminCounts.value.length }, { label: 'Admins aktiv', value: adminCounts.value.length },
{ label: 'Risk-User', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size }, { 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))
})
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="User & Logs" eyebrow="Team-Audit"
title="User-Spuren und Admin-Aktionen" title="Admin-Aktionen nachvollziehen"
description="Eine kompakte Kontrollansicht für Audit-Einträge, auffällige User und Admin-Aktivität. Für Detailentscheidungen bleibt Risiko & Audit der Hauptbereich." 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."
:icon="UserCog" :icon="UserCog"
/> />
@@ -108,26 +100,25 @@ const logStats = computed(() => [
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5"> <div class="border-b border-violet-100 p-5">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-rose-50 text-rose-600"> <div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<ShieldAlert class="h-5 w-5" /> <FileClock class="h-5 w-5" />
</div> </div>
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Auffällige User</p> <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">Aus Risk-Flags abgeleitet</h2> <h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Was wurde bearbeitet?</h2>
</div> </div>
</div> </div>
</div> </div>
<div class="divide-y divide-violet-50"> <div class="divide-y divide-violet-50">
<div v-for="user in filteredRiskUsers" :key="user.id" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_180px_140px] lg:items-center"> <div 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> <div>
<p class="font-semibold text-slate-900">{{ user.twitchUserId }}</p> <p class="font-semibold text-slate-900">{{ item.action }}</p>
<p class="mt-1 text-sm text-slate-500">{{ user.type }} · {{ user.ip }}</p> <p class="mt-1 text-sm text-slate-500">Audit-Kategorie für Team-Aktionen</p>
</div> </div>
<span class="text-sm text-slate-500">{{ new Date(user.createdAt).toLocaleString('de-DE') }}</span> <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>
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-center text-xs font-semibold uppercase tracking-[0.14em] text-rose-700">{{ user.severity }}</span>
</div> </div>
<p v-if="filteredRiskUsers.length === 0" class="px-5 py-10 text-center text-sm text-slate-500"> <p v-if="actionCounts.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Keine auffälligen User für den aktuellen Filter. Noch keine Aktionstypen vorhanden.
</p> </p>
</div> </div>
</Card> </Card>
+47 -38
View File
@@ -1,34 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { BarChart3, CheckCircle2, Tags, Users, Vote } from '@lucide/vue' import { CheckCircle2, LockKeyhole, ShieldAlert, Tags, Vote } from '@lucide/vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore() const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail) const seasonDetail = computed(() => store.adminSeasonDetail)
const totalVotes = computed(() => store.admin.metrics.find((metric) => metric.label === 'Stimmen')?.value ?? 0) const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
const maxVotes = computed(() => Math.max(...store.admin.topCategories.map((category) => category.votes), 1))
const votingReadiness = computed(() => const votingReadiness = computed(() =>
seasonDetail.value.categories.map((category) => { seasonDetail.value.categories.map((category) => {
const candidateCount = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length 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 { return {
...category, ...category,
candidateCount, candidateCount,
ready: candidateCount > 0 && seasonDetail.value.currentPhase.toLowerCase().includes('voting'), reviewCount,
ready: candidateCount > 0 && reviewCount === 0 && seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
} }
}), }),
) )
const readyCount = computed(() => votingReadiness.value.filter((category) => category.ready).length) const readyCount = computed(() => votingReadiness.value.filter((category) => category.ready).length)
const notReadyCategories = computed(() => votingReadiness.value.filter((category) => !category.ready)) 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(() => [ const stats = computed(() => [
{ label: 'Stimmen gesamt', value: totalVotes.value, icon: Vote }, { label: 'Stimmen gesamt', value: totalVotes.value, icon: Vote },
{ label: 'Voting-ready', value: readyCount.value, icon: CheckCircle2 }, { 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 }, { label: 'Kategorien', value: seasonDetail.value.categories.length, icon: Tags },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, icon: Users },
]) ])
const votingChecklist = computed(() => [ const votingChecklist = computed(() => [
{ {
@@ -39,7 +44,7 @@ const votingChecklist = computed(() => [
}, },
{ {
label: 'Alle Kategorien haben Kandidaten', label: 'Alle Kategorien haben Kandidaten',
done: notReadyCategories.value.every((category) => category.candidateCount > 0) && seasonDetail.value.categories.length > 0, 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`, note: `${notReadyCategories.value.filter((category) => category.candidateCount === 0).length} Kategorien ohne Kandidaten`,
to: '/admin/categories', to: '/admin/categories',
}, },
@@ -49,6 +54,12 @@ const votingChecklist = computed(() => [
note: `${seasonDetail.value.pendingNominations.length} offene Reviews`, note: `${seasonDetail.value.pendingNominations.length} offene Reviews`,
to: '/admin/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> </script>
@@ -56,8 +67,8 @@ const votingChecklist = computed(() => [
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Voting" eyebrow="Voting"
title="Voting-Status und Rankings" title="Voting freigeben und absichern"
description="Prüfe, ob Kategorien Kandidaten besitzen, ob das Jahr in der richtigen Phase ist und welche Kategorien aktuell die meiste Aktivität erzeugen." 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" :icon="Vote"
/> />
@@ -78,34 +89,6 @@ const votingChecklist = computed(() => [
</section> </section>
<section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]"> <section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
<Card class="p-6">
<div class="flex items-end justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Ranking</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien</h2>
</div>
<BarChart3 class="h-6 w-6 text-violet-500" />
</div>
<div class="mt-6 space-y-4">
<div v-for="(category, index) in store.admin.topCategories" :key="category.category" class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-violet-500">#{{ index + 1 }}</p>
<h3 class="mt-1 font-semibold text-slate-900">{{ category.category }}</h3>
</div>
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
</div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-violet-50">
<div class="h-full rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
</div>
</div>
<p v-if="store.admin.topCategories.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-8 text-center text-sm text-slate-500">
Noch keine Voting-Daten vorhanden.
</p>
</div>
</Card>
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5"> <div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
@@ -115,7 +98,7 @@ const votingChecklist = computed(() => [
<div v-for="category in votingReadiness" :key="category.id" class="grid grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4"> <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"> <div class="min-w-0">
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p> <p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidateCount }} Kandidaten</p> <p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidateCount }} Kandidaten · {{ category.reviewCount }} Reviews</p>
</div> </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'"> <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' }} {{ category.ready ? 'bereit' : 'prüfen' }}
@@ -123,12 +106,38 @@ const votingChecklist = computed(() => [
</div> </div>
</div> </div>
</Card> </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> </section>
<Card class="p-6"> <Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Voting Checkliste</p> <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> <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-3"> <div class="mt-5 grid gap-3 lg:grid-cols-4">
<RouterLink <RouterLink
v-for="item in votingChecklist" v-for="item in votingChecklist"
:key="item.label" :key="item.label"