Refactor admin panel into categorized navigation
This commit is contained in:
@@ -25,7 +25,7 @@ const navItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const currentLabel = computed(
|
const currentLabel = computed(
|
||||||
() => navItems.find((item) => item.to === route.path)?.label ?? 'Awards',
|
() => navItems.find((item) => route.path === item.to || route.path.startsWith(`${item.to}/`))?.label ?? 'Awards',
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleNavItems = computed(() =>
|
const visibleNavItems = computed(() =>
|
||||||
@@ -103,7 +103,7 @@ async function login(role: 'viewer' | 'admin') {
|
|||||||
:key="item.to"
|
:key="item.to"
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700"
|
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700"
|
||||||
:class="route.path === item.to ? 'bg-violet-100 text-violet-800' : ''"
|
:class="route.path === item.to || route.path.startsWith(`${item.to}/`) ? 'bg-violet-100 text-violet-800' : ''"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Select from 'primevue/select'
|
||||||
|
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
|
||||||
|
const seasons = computed(() => store.adminSeasons)
|
||||||
|
const selectedSeasonId = computed({
|
||||||
|
get: () => store.adminSelectedSeasonId,
|
||||||
|
set: async (value) => {
|
||||||
|
if (!value) return
|
||||||
|
await store.loadAdminSeasonDetail(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const seasonOptions = computed(() =>
|
||||||
|
seasons.value.map((season) => ({
|
||||||
|
label: `${season.year} · ${season.name}`,
|
||||||
|
value: season.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4 rounded-[26px] border border-violet-100 bg-white/80 px-5 py-5 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Arbeitskontext</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Die gewaehlte Season steuert Kategorien, Kandidaten und Review-Queues im gesamten Admin-Bereich.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:min-w-[330px]">
|
||||||
|
<label class="text-sm font-semibold text-slate-600">Season</label>
|
||||||
|
<Select
|
||||||
|
v-model="selectedSeasonId"
|
||||||
|
:options="seasonOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+38
-3
@@ -1,7 +1,12 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
|
||||||
import AdminView from './views/AdminView.vue'
|
import AdminCandidatesView from './views/admin/AdminCandidatesView.vue'
|
||||||
|
import AdminDashboardView from './views/admin/AdminDashboardView.vue'
|
||||||
|
import AdminLayoutView from './views/admin/AdminLayoutView.vue'
|
||||||
|
import AdminReviewsView from './views/admin/AdminReviewsView.vue'
|
||||||
|
import AdminRiskView from './views/admin/AdminRiskView.vue'
|
||||||
|
import AdminSeasonsView from './views/admin/AdminSeasonsView.vue'
|
||||||
import HomeView from './views/HomeView.vue'
|
import HomeView from './views/HomeView.vue'
|
||||||
import NominationsView from './views/NominationsView.vue'
|
import NominationsView from './views/NominationsView.vue'
|
||||||
import VotingView from './views/VotingView.vue'
|
import VotingView from './views/VotingView.vue'
|
||||||
@@ -41,11 +46,41 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
name: 'admin',
|
component: AdminLayoutView,
|
||||||
component: AdminView,
|
|
||||||
meta: {
|
meta: {
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
},
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: { name: 'admin-dashboard' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'admin-dashboard',
|
||||||
|
component: AdminDashboardView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'seasons',
|
||||||
|
name: 'admin-seasons',
|
||||||
|
component: AdminSeasonsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'candidates',
|
||||||
|
name: 'admin-candidates',
|
||||||
|
component: AdminCandidatesView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reviews',
|
||||||
|
name: 'admin-reviews',
|
||||||
|
component: AdminReviewsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'risk',
|
||||||
|
name: 'admin-risk',
|
||||||
|
component: AdminRiskView,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export const useAwardsStore = defineStore('awards', {
|
|||||||
admin: fallbackAdmin as AdminDashboardResponse,
|
admin: fallbackAdmin as AdminDashboardResponse,
|
||||||
adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[],
|
adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[],
|
||||||
adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse,
|
adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse,
|
||||||
|
adminSelectedSeasonId: fallbackAdminSeasonDetail.id as number | null,
|
||||||
loading: false,
|
loading: false,
|
||||||
apiMode: 'fallback' as 'api' | 'fallback',
|
apiMode: 'fallback' as 'api' | 'fallback',
|
||||||
}),
|
}),
|
||||||
@@ -237,22 +238,39 @@ export const useAwardsStore = defineStore('awards', {
|
|||||||
try {
|
try {
|
||||||
this.admin = await api.getAdminDashboard()
|
this.admin = await api.getAdminDashboard()
|
||||||
this.adminSeasons = await api.getAdminSeasons()
|
this.adminSeasons = await api.getAdminSeasons()
|
||||||
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSeasons[0]?.id ?? 1)
|
if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) {
|
||||||
|
this.adminSelectedSeasonId = this.adminSeasons[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.adminSelectedSeasonId) {
|
||||||
|
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSelectedSeasonId)
|
||||||
|
}
|
||||||
this.apiMode = 'api'
|
this.apiMode = 'api'
|
||||||
} catch {
|
} catch {
|
||||||
this.admin = emptyAdmin
|
this.admin = emptyAdmin
|
||||||
this.adminSeasons = emptyAdminSeasons
|
this.adminSeasons = emptyAdminSeasons
|
||||||
this.adminSeasonDetail = emptyAdminSeasonDetail
|
this.adminSeasonDetail = emptyAdminSeasonDetail
|
||||||
|
this.adminSelectedSeasonId = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async loadAdminSeasonDetail(seasonId: number) {
|
async loadAdminSeasonDetail(seasonId: number) {
|
||||||
try {
|
try {
|
||||||
|
this.adminSelectedSeasonId = seasonId
|
||||||
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId)
|
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId)
|
||||||
this.apiMode = 'api'
|
this.apiMode = 'api'
|
||||||
} catch {
|
} catch {
|
||||||
this.adminSeasonDetail = emptyAdminSeasonDetail
|
this.adminSeasonDetail = emptyAdminSeasonDetail
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async initializeAdminWorkspace() {
|
||||||
|
await this.loadAdmin()
|
||||||
|
if (this.adminSelectedSeasonId && this.adminSeasonDetail.id !== this.adminSelectedSeasonId) {
|
||||||
|
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setAdminSeason(seasonId: number) {
|
||||||
|
this.adminSelectedSeasonId = seasonId
|
||||||
|
},
|
||||||
submitNomination(payload: CreateNominationPayload) {
|
submitNomination(payload: CreateNominationPayload) {
|
||||||
return api.submitNomination(payload)
|
return api.submitNomination(payload)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,654 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import Column from 'primevue/column'
|
|
||||||
import DataTable from 'primevue/datatable'
|
|
||||||
import Select from 'primevue/select'
|
|
||||||
|
|
||||||
import Button from '../components/ui/Button.vue'
|
|
||||||
import Card from '../components/ui/Card.vue'
|
|
||||||
import { useAwardsStore } from '../stores/awards'
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
|
|
||||||
const store = useAwardsStore()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const selectedSeasonId = ref<number | null>(null)
|
|
||||||
const seasonSaving = ref(false)
|
|
||||||
const categorySaving = ref<number | 'new' | null>(null)
|
|
||||||
const candidateSaving = ref<number | 'new' | null>(null)
|
|
||||||
const reviewSaving = ref<number | null>(null)
|
|
||||||
const riskSaving = ref<number | null>(null)
|
|
||||||
const adminMessage = ref('')
|
|
||||||
const adminError = ref('')
|
|
||||||
|
|
||||||
const seasonForm = reactive({
|
|
||||||
currentPhase: '',
|
|
||||||
isCurrent: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newCategoryForm = reactive({
|
|
||||||
groupName: '',
|
|
||||||
name: '',
|
|
||||||
slug: '',
|
|
||||||
description: '',
|
|
||||||
sortOrder: 1,
|
|
||||||
maxNomineesPerUser: 3,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newCandidateForm = reactive({
|
|
||||||
categoryId: 0,
|
|
||||||
displayName: '',
|
|
||||||
channelSlug: '',
|
|
||||||
platform: 'Twitch',
|
|
||||||
})
|
|
||||||
|
|
||||||
const editForms = reactive<Record<number, {
|
|
||||||
groupName: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
description: string
|
|
||||||
sortOrder: number
|
|
||||||
maxNomineesPerUser: number
|
|
||||||
}>>({})
|
|
||||||
|
|
||||||
const candidateForms = reactive<Record<number, {
|
|
||||||
categoryId: number
|
|
||||||
displayName: string
|
|
||||||
channelSlug: string
|
|
||||||
platform: string
|
|
||||||
}>>({})
|
|
||||||
|
|
||||||
const reviewForms = reactive<Record<number, {
|
|
||||||
displayName: string
|
|
||||||
channelSlug: string
|
|
||||||
platform: string
|
|
||||||
}>>({})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!authStore.isAdmin) return
|
|
||||||
await store.loadAdmin()
|
|
||||||
selectedSeasonId.value = store.adminSeasons[0]?.id ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const metrics = computed(() => store.admin.metrics)
|
|
||||||
const activities = computed(() => store.admin.activities)
|
|
||||||
const topCategories = computed(() => store.admin.topCategories)
|
|
||||||
const riskFlags = computed(() => store.admin.riskFlags)
|
|
||||||
const auditEntries = computed(() => store.admin.auditEntries)
|
|
||||||
const seasons = computed(() => store.adminSeasons)
|
|
||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
|
||||||
const categoryOptions = computed(() =>
|
|
||||||
seasonDetail.value.categories.map((category) => ({
|
|
||||||
label: `${category.groupName} · ${category.name}`,
|
|
||||||
value: category.id,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(selectedSeasonId, async (seasonId) => {
|
|
||||||
if (!seasonId) return
|
|
||||||
await store.loadAdminSeasonDetail(seasonId)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
seasonDetail,
|
|
||||||
(detail) => {
|
|
||||||
seasonForm.currentPhase = detail.currentPhase
|
|
||||||
seasonForm.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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of detail.candidates) {
|
|
||||||
candidateForms[candidate.id] = {
|
|
||||||
categoryId: candidate.categoryId,
|
|
||||||
displayName: candidate.displayName,
|
|
||||||
channelSlug: candidate.channelSlug,
|
|
||||||
platform: candidate.platform,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const nomination of detail.pendingNominations) {
|
|
||||||
reviewForms[nomination.id] = {
|
|
||||||
displayName: nomination.candidateText,
|
|
||||||
channelSlug: '',
|
|
||||||
platform: 'Twitch',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const seasonOptions = computed(() =>
|
|
||||||
seasons.value.map((season) => ({
|
|
||||||
label: `${season.year} · ${season.name}`,
|
|
||||||
value: season.id,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function saveSeason() {
|
|
||||||
if (!selectedSeasonId.value) return
|
|
||||||
|
|
||||||
seasonSaving.value = true
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.updateAdminSeason(selectedSeasonId.value, {
|
|
||||||
currentPhase: seasonForm.currentPhase,
|
|
||||||
isCurrent: seasonForm.isCurrent,
|
|
||||||
})
|
|
||||||
await store.loadAdminSeasonDetail(selectedSeasonId.value)
|
|
||||||
adminMessage.value = 'Season-Einstellungen gespeichert.'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
|
|
||||||
} finally {
|
|
||||||
seasonSaving.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCandidate(candidateId: number) {
|
|
||||||
if (!selectedSeasonId.value) return
|
|
||||||
|
|
||||||
candidateSaving.value = candidateId
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
|
|
||||||
adminMessage.value = 'Kandidat gespeichert.'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
|
|
||||||
} finally {
|
|
||||||
candidateSaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCandidate() {
|
|
||||||
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return
|
|
||||||
|
|
||||||
candidateSaving.value = 'new'
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
|
|
||||||
adminMessage.value = 'Kandidat angelegt.'
|
|
||||||
newCandidateForm.displayName = ''
|
|
||||||
newCandidateForm.channelSlug = ''
|
|
||||||
newCandidateForm.platform = 'Twitch'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
|
|
||||||
} finally {
|
|
||||||
candidateSaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 uebernommen.'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen 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 Queue entfernt.'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
|
||||||
} finally {
|
|
||||||
reviewSaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
|
||||||
riskSaving.value = riskFlagId
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.resolveRiskFlag(riskFlagId, status)
|
|
||||||
adminMessage.value = `Risk Flag ${riskFlagId} wurde als ${status} markiert.`
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Risk Flag konnte nicht aktualisiert werden.'
|
|
||||||
} finally {
|
|
||||||
riskSaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-10 pb-14">
|
|
||||||
<Card v-if="!authStore.isAdmin" class="p-8">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin Access</p>
|
|
||||||
<h1 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Admin Login erforderlich</h1>
|
|
||||||
<p class="mt-4 max-w-2xl text-lg leading-8 text-slate-600">
|
|
||||||
Bitte melde dich ueber den Header mit einem Admin-Login an, damit Season-, Category-, Candidate- und Review-Management verfuegbar werden.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
|
|
||||||
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Betriebswerkzeug fuer Seasons, Kategorien, Kandidaten und Review-Flows</h1>
|
|
||||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
|
||||||
Das Team pflegt das Jahres-Setup und die operativen Awards-Inhalte direkt aus einer zusammenhaengenden Admin-Oberflaeche.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-5 lg:grid-cols-4">
|
|
||||||
<Card
|
|
||||||
v-for="metric in metrics"
|
|
||||||
:key="metric.label"
|
|
||||||
class="p-7"
|
|
||||||
>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
|
|
||||||
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Aktive Season auswaehlen, Phase anpassen und bei Bedarf zum aktuellen Jahr machen.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="text-sm font-semibold text-slate-600">Season</label>
|
|
||||||
<Select
|
|
||||||
v-model="selectedSeasonId"
|
|
||||||
:options="seasonOptions"
|
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="text-sm font-semibold text-slate-600">Phase</label>
|
|
||||||
<input
|
|
||||||
v-model="seasonForm.currentPhase"
|
|
||||||
type="text"
|
|
||||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
|
|
||||||
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
|
|
||||||
Diese Season ist die aktuelle Public Season
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
|
||||||
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
|
|
||||||
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
|
|
||||||
</Button>
|
|
||||||
<span class="text-sm text-slate-500">
|
|
||||||
{{ seasonDetail.year }} · {{ seasonDetail.name }}
|
|
||||||
</span>
|
|
||||||
</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="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
|
||||||
{{ adminError }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="p-7">
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
|
|
||||||
<DataTable :value="topCategories" class="mt-6" striped-rows>
|
|
||||||
<Column field="category" header="Kategorie" />
|
|
||||||
<Column field="votes" header="Votes">
|
|
||||||
<template #body="{ data }">
|
|
||||||
{{ Number(data.votes).toLocaleString('de-DE') }}
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</DataTable>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risk Center</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nomination- und Voting-Muster fuer die manuelle Sichtung.</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
|
||||||
{{ riskFlags.length }} offen
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="flag in riskFlags"
|
|
||||||
: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>
|
|
||||||
|
|
||||||
<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 ...' : 'Dismiss' }}
|
|
||||||
</Button>
|
|
||||||
<Button :disabled="riskSaving === flag.id" @click="resolveRiskFlag(flag.id, 'resolved')">
|
|
||||||
{{ riskSaving === flag.id ? 'Speichert ...' : 'Resolve' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit Log</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
|
||||||
{{ auditEntries.length }} Eintraege
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="entry in auditEntries"
|
|
||||||
: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>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
|
||||||
{{ seasonDetail.categories.length }} Kategorien
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="category in seasonDetail.categories"
|
|
||||||
:key="category.id"
|
|
||||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
|
|
||||||
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
|
|
||||||
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
|
||||||
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
|
||||||
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
|
|
||||||
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
|
||||||
{{ category.candidateCount }} Kandidaten in dieser Kategorie
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
v-model="editForms[category.id].description"
|
|
||||||
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
|
|
||||||
placeholder="Beschreibung"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
|
|
||||||
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="p-7">
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
|
|
||||||
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
|
|
||||||
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
|
||||||
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
|
||||||
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
|
|
||||||
</div>
|
|
||||||
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
|
|
||||||
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting/Archiv genutzt werden.</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
|
||||||
{{ seasonDetail.candidates.length }} Kandidaten
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="candidate in seasonDetail.candidates"
|
|
||||||
:key="candidate.id"
|
|
||||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
<Select
|
|
||||||
v-model="candidateForms[candidate.id].categoryId"
|
|
||||||
:options="categoryOptions"
|
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
|
||||||
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
|
||||||
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
|
|
||||||
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="p-7">
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<Select
|
|
||||||
v-model="newCandidateForm.categoryId"
|
|
||||||
:options="categoryOptions"
|
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
|
||||||
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
|
||||||
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
|
||||||
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
|
|
||||||
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="mt-10 font-[Cormorant_Garamond] text-3xl text-violet-800">Letzte Aktivitaeten</h3>
|
|
||||||
<div class="mt-4 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="activity in activities"
|
|
||||||
:key="activity.label"
|
|
||||||
class="rounded-[26px] 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-1 text-sm text-slate-500">{{ activity.age }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
|
||||||
{{ seasonDetail.pendingNominations.length }} offen
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="nomination in seasonDetail.pendingNominations"
|
|
||||||
:key="nomination.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">{{ nomination.categoryName }}</p>
|
|
||||||
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">
|
|
||||||
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
|
||||||
ID {{ nomination.id }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 grid gap-4 md:grid-cols-3">
|
|
||||||
<input
|
|
||||||
v-model="reviewForms[nomination.id].displayName"
|
|
||||||
type="text"
|
|
||||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
|
||||||
placeholder="Display Name"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="reviewForms[nomination.id].channelSlug"
|
|
||||||
type="text"
|
|
||||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
|
||||||
placeholder="@channel"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="reviewForms[nomination.id].platform"
|
|
||||||
type="text"
|
|
||||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
|
||||||
placeholder="Platform"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
|
||||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
|
|
||||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
|
||||||
</Button>
|
|
||||||
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
|
|
||||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import Select from 'primevue/select'
|
||||||
|
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const candidateSaving = ref<number | 'new' | null>(null)
|
||||||
|
const adminMessage = ref('')
|
||||||
|
const adminError = ref('')
|
||||||
|
|
||||||
|
const newCandidateForm = reactive({
|
||||||
|
categoryId: 0,
|
||||||
|
displayName: '',
|
||||||
|
channelSlug: '',
|
||||||
|
platform: 'Twitch',
|
||||||
|
})
|
||||||
|
|
||||||
|
const candidateForms = reactive<Record<number, {
|
||||||
|
categoryId: number
|
||||||
|
displayName: string
|
||||||
|
channelSlug: string
|
||||||
|
platform: string
|
||||||
|
}>>({})
|
||||||
|
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
const categoryOptions = computed(() =>
|
||||||
|
seasonDetail.value.categories.map((category) => ({
|
||||||
|
label: `${category.groupName} · ${category.name}`,
|
||||||
|
value: category.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
seasonDetail,
|
||||||
|
(detail) => {
|
||||||
|
for (const candidate of detail.candidates) {
|
||||||
|
candidateForms[candidate.id] = {
|
||||||
|
categoryId: candidate.categoryId,
|
||||||
|
displayName: candidate.displayName,
|
||||||
|
channelSlug: candidate.channelSlug,
|
||||||
|
platform: candidate.platform,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function saveCandidate(candidateId: number) {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
|
||||||
|
candidateSaving.value = candidateId
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
|
||||||
|
adminMessage.value = 'Kandidat gespeichert.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
|
||||||
|
} finally {
|
||||||
|
candidateSaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCandidate() {
|
||||||
|
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return
|
||||||
|
|
||||||
|
candidateSaving.value = 'new'
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
|
||||||
|
adminMessage.value = 'Kandidat angelegt.'
|
||||||
|
newCandidateForm.displayName = ''
|
||||||
|
newCandidateForm.channelSlug = ''
|
||||||
|
newCandidateForm.platform = 'Twitch'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
|
||||||
|
} finally {
|
||||||
|
candidateSaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting und Archiv genutzt werden.</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
{{ seasonDetail.candidates.length }} Kandidaten
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="candidate in seasonDetail.candidates"
|
||||||
|
:key="candidate.id"
|
||||||
|
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
v-model="candidateForms[candidate.id].categoryId"
|
||||||
|
:options="categoryOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
||||||
|
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
||||||
|
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
|
||||||
|
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-7">
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<Select
|
||||||
|
v-model="newCandidateForm.categoryId"
|
||||||
|
:options="categoryOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
||||||
|
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
||||||
|
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
||||||
|
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
|
||||||
|
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
|
||||||
|
const metrics = computed(() => store.admin.metrics)
|
||||||
|
const activities = computed(() => store.admin.activities)
|
||||||
|
const topCategories = computed(() => store.admin.topCategories)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-5 lg:grid-cols-4">
|
||||||
|
<Card
|
||||||
|
v-for="metric in metrics"
|
||||||
|
:key="metric.label"
|
||||||
|
class="p-7"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
|
||||||
|
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<Card class="p-7">
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
|
||||||
|
<DataTable :value="topCategories" class="mt-6" striped-rows>
|
||||||
|
<Column field="category" header="Kategorie" />
|
||||||
|
<Column field="votes" header="Votes">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ Number(data.votes).toLocaleString('de-DE') }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-7">
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Letzte Aktivitaeten</h2>
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="activity in activities"
|
||||||
|
:key="activity.label"
|
||||||
|
class="rounded-[26px] 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-1 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-Aktivitaeten vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Dashboard', to: '/admin/dashboard', description: 'KPIs, Trends, letzte Aktivitaet' },
|
||||||
|
{ label: 'Seasons', to: '/admin/seasons', description: 'Season-Status, Kategorien, Limits' },
|
||||||
|
{ label: 'Candidates', to: '/admin/candidates', description: 'Kandidatenbasis pro Season pflegen' },
|
||||||
|
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Nominierungen bearbeiten' },
|
||||||
|
{ label: 'Risk & Audit', to: '/admin/risk', description: 'Flags pruefen, Aktionen nachvollziehen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentSeason = computed(() => store.adminSeasonDetail)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!authStore.isAdmin) return
|
||||||
|
await store.initializeAdminWorkspace()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-8 pb-14">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
|
||||||
|
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">
|
||||||
|
Betriebswerkzeug fuer Awards, Moderation und Risiko-Sichtung
|
||||||
|
</h1>
|
||||||
|
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||||
|
Der Admin-Bereich ist in klar getrennte Arbeitszonen aufgeteilt, damit Season-Pflege, Review und Monitoring nicht mehr auf einer einzigen Seite kollidieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
|
<Card class="h-fit p-4">
|
||||||
|
<nav class="space-y-2">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="block rounded-[24px] border px-4 py-4 transition"
|
||||||
|
:class="route.path === item.to ? 'border-violet-200 bg-violet-100/80 text-violet-900' : 'border-transparent bg-white/70 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-semibold uppercase tracking-[0.18em]">{{ item.label }}</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">{{ item.description }}</p>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-[24px] border border-violet-100 bg-violet-50/60 px-4 py-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktive Season</p>
|
||||||
|
<h2 class="mt-3 font-[Cormorant_Garamond] text-3xl text-violet-800">
|
||||||
|
{{ currentSeason.year ? `${currentSeason.year}` : 'Keine Season' }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">{{ currentSeason.name || 'Bitte Season auswaehlen.' }}</p>
|
||||||
|
<p class="mt-3 text-xs uppercase tracking-[0.2em] text-slate-500">{{ currentSeason.currentPhase || 'Kein Status' }}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const 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)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
seasonDetail,
|
||||||
|
(detail) => {
|
||||||
|
for (const nomination of detail.pendingNominations) {
|
||||||
|
reviewForms[nomination.id] = {
|
||||||
|
displayName: nomination.candidateText,
|
||||||
|
channelSlug: '',
|
||||||
|
platform: 'Twitch',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ 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 uebernommen.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen 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 Queue entfernt.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
||||||
|
} finally {
|
||||||
|
reviewSaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
{{ seasonDetail.pendingNominations.length }} offen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="mt-6 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="nomination in seasonDetail.pendingNominations"
|
||||||
|
:key="nomination.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">{{ nomination.categoryName }}</p>
|
||||||
|
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
ID {{ nomination.id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 md:grid-cols-3">
|
||||||
|
<input
|
||||||
|
v-model="reviewForms[nomination.id].displayName"
|
||||||
|
type="text"
|
||||||
|
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="Display Name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="reviewForms[nomination.id].channelSlug"
|
||||||
|
type="text"
|
||||||
|
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="@channel"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="reviewForms[nomination.id].platform"
|
||||||
|
type="text"
|
||||||
|
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="Platform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||||
|
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
|
||||||
|
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
|
||||||
|
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine offenen Review-Faelle in der aktuell gewaehlten Season.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
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 riskFlags = computed(() => store.admin.riskFlags)
|
||||||
|
const auditEntries = computed(() => store.admin.auditEntries)
|
||||||
|
|
||||||
|
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||||
|
riskSaving.value = riskFlagId
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.resolveRiskFlag(riskFlagId, status)
|
||||||
|
adminMessage.value = `Risk Flag ${riskFlagId} wurde als ${status} markiert.`
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Risk Flag konnte nicht aktualisiert werden.'
|
||||||
|
} finally {
|
||||||
|
riskSaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risk Center</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nominierungs- und Voting-Muster fuer die manuelle Sichtung.</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
{{ riskFlags.length }} offen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="mt-6 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="flag in riskFlags"
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<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 ...' : 'Dismiss' }}
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="riskSaving === flag.id" @click="resolveRiskFlag(flag.id, 'resolved')">
|
||||||
|
{{ riskSaving === flag.id ? 'Speichert ...' : 'Resolve' }}
|
||||||
|
</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 Risk Flags vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit Log</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
{{ auditEntries.length }} Eintraege
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="entry in auditEntries"
|
||||||
|
: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-Eintraege vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const seasonSaving = ref(false)
|
||||||
|
const categorySaving = ref<number | 'new' | null>(null)
|
||||||
|
const adminMessage = ref('')
|
||||||
|
const adminError = ref('')
|
||||||
|
|
||||||
|
const seasonForm = reactive({
|
||||||
|
currentPhase: '',
|
||||||
|
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 selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
seasonDetail,
|
||||||
|
(detail) => {
|
||||||
|
seasonForm.currentPhase = detail.currentPhase
|
||||||
|
seasonForm.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
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function saveSeason() {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
|
||||||
|
seasonSaving.value = true
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.updateAdminSeason(selectedSeasonId.value, {
|
||||||
|
currentPhase: seasonForm.currentPhase,
|
||||||
|
isCurrent: seasonForm.isCurrent,
|
||||||
|
})
|
||||||
|
adminMessage.value = 'Season-Einstellungen gespeichert.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
|
||||||
|
} finally {
|
||||||
|
seasonSaving.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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Phase, Current-Status und Basiskontext fuer die aktive Awards-Season.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="text-sm font-semibold text-slate-600">Phase</label>
|
||||||
|
<input
|
||||||
|
v-model="seasonForm.currentPhase"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
|
||||||
|
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
|
||||||
|
Diese Season ist die aktuelle Public Season
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
|
||||||
|
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
|
||||||
|
</Button>
|
||||||
|
<span class="text-sm text-slate-500">
|
||||||
|
{{ seasonDetail.year }} · {{ seasonDetail.name }}
|
||||||
|
</span>
|
||||||
|
</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="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{{ adminError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-7">
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
|
||||||
|
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
|
||||||
|
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
||||||
|
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
||||||
|
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
|
||||||
|
</div>
|
||||||
|
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
|
||||||
|
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
{{ seasonDetail.categories.length }} Kategorien
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="category in seasonDetail.categories"
|
||||||
|
:key="category.id"
|
||||||
|
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
|
||||||
|
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
|
||||||
|
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
||||||
|
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
||||||
|
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
|
||||||
|
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
{{ category.candidateCount }} Kandidaten in dieser Kategorie
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
v-model="editForms[category.id].description"
|
||||||
|
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="Beschreibung"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
|
||||||
|
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user