Refine admin panel workflows
This commit is contained in:
+1
-1
@@ -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"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}` },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 fü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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user