Refine admin workflows and team access

This commit is contained in:
AzuTear
2026-06-26 18:09:29 +02:00
parent b7804e10ea
commit 8b69dfbafb
71 changed files with 5066 additions and 751 deletions
+233 -85
View File
@@ -1,22 +1,47 @@
<script setup lang="ts">
import { BarChart3, CheckCircle2 } from '@lucide/vue'
import { computed, ref } from 'vue'
import { BarChart3, CheckCircle2, ExternalLink, TrendingUp } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminAnalyticsManager } from '../../components/admin/useAdminAnalyticsManager'
import Card from '../../components/ui/Card.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
const {
categoryHealth,
categoryGroups,
metricCards,
readinessCards,
insightCards,
attentionItems,
topCategories,
maxVotes,
topCategoriesEnriched,
winnerCoveragePct,
readinessPct,
healthSummary,
totalVotes,
} = useAdminAnalyticsManager()
const healthFilter = ref<string>('Alle')
const groupFilter = ref<string>('Alle')
const groupOptions = computed(() => ['Alle', ...categoryGroups.value.map((g) => g.name)])
const filteredHealth = computed(() => {
let list = categoryHealth.value
if (healthFilter.value !== 'Alle') list = list.filter((c) => c.status === healthFilter.value)
if (groupFilter.value !== 'Alle') list = list.filter((c) => (c.groupName || 'Ohne Gruppe') === groupFilter.value)
return list
})
const healthStatusOptions = [
{ key: 'Alle', label: 'Alle' },
{ key: 'Leer', label: 'Leer', dot: 'bg-rose-400' },
{ key: 'Review offen', label: 'Review offen', dot: 'bg-amber-400' },
{ key: 'Bereit', label: 'Bereit', dot: 'bg-sky-400' },
{ key: 'Gewinner gesetzt', label: 'Gewinner', dot: 'bg-emerald-400' },
]
</script>
<template>
@@ -29,6 +54,7 @@ const {
<AdminSeasonToolbar />
<!-- Metric cards -->
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card v-for="metric in metricCards" :key="metric.label" class="p-5">
<div class="flex items-start justify-between gap-4">
@@ -44,15 +70,46 @@ const {
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<!-- Readiness + Attention row -->
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<!-- Readiness -->
<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">Readiness</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Finale Vollständigkeit</h2>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50 px-4 py-3 text-sm font-bold text-violet-800">
{{ winnerCoveragePct }}% Gewinner
<div class="text-right">
<div class="text-2xl font-black text-violet-700">{{ winnerCoveragePct }}%</div>
<div class="text-xs font-semibold text-slate-500">Gewinner</div>
</div>
</div>
<!-- Progress bar -->
<div class="mt-4 space-y-2">
<div class="flex items-center justify-between text-xs font-semibold text-slate-500">
<span>Gesamt-Readiness</span><span>{{ readinessPct }}%</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
<div
class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#22c55e)] transition-all duration-500"
:style="{ width: `${readinessPct}%` }"
/>
</div>
<!-- Health segments legend -->
<div class="flex flex-wrap gap-x-4 gap-y-1 pt-1">
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-rose-600">
<span class="h-2 w-2 rounded-full bg-rose-400" />{{ healthSummary.leer }} Leer
</span>
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-amber-600">
<span class="h-2 w-2 rounded-full bg-amber-400" />{{ healthSummary.reviewOffen }} Review offen
</span>
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-sky-600">
<span class="h-2 w-2 rounded-full bg-sky-400" />{{ healthSummary.bereit }} Bereit
</span>
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600">
<span class="h-2 w-2 rounded-full bg-emerald-400" />{{ healthSummary.gewinner }} Gewinner
</span>
</div>
</div>
@@ -75,93 +132,184 @@ const {
</div>
</Card>
<Card class="p-6">
<div>
<!-- Attention + Insights -->
<div class="space-y-6">
<!-- Attention items -->
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aufmerksamkeit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was Admins zuerst prüfen sollten</h2>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<RouterLink
v-for="item in attentionItems"
:key="item.key"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:translate-y-[-1px]"
:class="item.tone"
>
<strong class="block text-3xl leading-none">{{ item.value }}</strong>
<span class="mt-3 block text-sm font-bold">{{ item.label }}</span>
<span class="mt-1 block text-xs leading-5 opacity-80">{{ item.note }}</span>
</RouterLink>
</div>
<div class="mt-6 grid gap-3 md:grid-cols-3">
<div v-for="insight in insightCards" :key="insight.label" class="rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
<component :is="insight.icon" class="h-5 w-5 text-violet-600" />
<p class="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{{ insight.label }}</p>
<strong class="mt-1 block text-2xl text-slate-950">{{ insight.value }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ insight.note }}</p>
</div>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Kandidaten, Reviews und Gewinnerstatus</h2>
</div>
<div class="max-h-[520px] overflow-y-auto p-4">
<div class="grid gap-3">
<h2 class="mt-1 text-xl font-bold text-slate-900">Was zuerst prüfen</h2>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<RouterLink
v-for="category in categoryHealth"
:key="category.id"
to="/admin/categories"
class="grid gap-3 rounded-[22px] border border-violet-100 bg-white p-4 transition hover:bg-violet-50/60 md:grid-cols-[minmax(0,1fr)_110px_110px_130px]"
v-for="item in attentionItems"
:key="item.key"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:translate-y-[-1px]"
:class="item.tone"
>
<span class="min-w-0">
<strong class="block truncate text-slate-950">{{ category.name }}</strong>
<span class="mt-1 block text-sm text-slate-500">{{ category.groupName || 'Ohne Gruppe' }}</span>
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.candidates }}</strong>
Kandidaten
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
Stimmen
</span>
<span class="inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
{{ category.status }}
</span>
<strong class="block text-3xl leading-none">{{ item.value }}</strong>
<span class="mt-3 block text-sm font-bold">{{ item.label }}</span>
<span class="mt-1 block text-xs leading-5 opacity-80">{{ item.note }}</span>
</RouterLink>
</div>
</div>
</Card>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Top Kategorien</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Stimmenverteilung</h2>
<div class="mt-5 space-y-4">
<div v-for="category in topCategories.slice(0, 8)" :key="category.category">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="truncate font-semibold text-slate-800">{{ category.category }}</span>
<span class="shrink-0 text-slate-500">{{ category.votes.toLocaleString('de-DE') }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-violet-100">
<div class="h-full rounded-full bg-violet-600" :style="{ width: `${Math.max(4, Math.round((category.votes / maxVotes) * 100))}%` }"></div>
<!-- Insight cards -->
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Einblicke</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Kennzahlen</h2>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<div v-for="insight in insightCards" :key="insight.label" class="rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
<component :is="insight.icon" class="h-5 w-5 text-violet-600" />
<p class="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{{ insight.label }}</p>
<strong class="mt-1 block text-2xl text-slate-950">{{ insight.value }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ insight.note }}</p>
</div>
</div>
<div v-if="topCategories.length === 0" class="rounded-[22px] border border-dashed border-violet-100 p-5 text-sm text-slate-500">
Noch keine Stimmenverteilung vorhanden.
</div>
</div>
<div class="mt-6 flex items-center gap-2 rounded-[22px] border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-800">
<CheckCircle2 class="h-5 w-5 shrink-0" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
</div>
</Card>
</Card>
</div>
</section>
<!-- Vote distribution bar chart (improved) -->
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] p-5">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Stimmenverteilung</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Top Kategorien nach Votes</h2>
</div>
<div class="flex items-center gap-2 rounded-2xl border border-violet-100 bg-white px-4 py-2.5">
<TrendingUp class="h-4 w-4 text-violet-600" />
<span class="text-sm font-bold text-violet-800">{{ totalVotes.toLocaleString('de-DE') }} Gesamt</span>
</div>
</div>
</div>
<div v-if="topCategoriesEnriched.length > 0" class="divide-y divide-violet-50">
<div
v-for="(cat, index) in topCategoriesEnriched"
:key="cat.category"
class="group/bar px-6 py-4 transition hover:bg-violet-50/40"
>
<div class="flex items-center gap-4">
<!-- Rank -->
<span class="w-6 shrink-0 text-center text-xs font-black text-slate-400">{{ index + 1 }}</span>
<!-- Name + bar -->
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-4">
<span class="truncate text-sm font-semibold text-slate-800">{{ cat.category }}</span>
<div class="flex shrink-0 items-center gap-3">
<!-- Pct badge -->
<span class="min-w-[44px] text-right text-xs font-bold text-violet-600 opacity-0 transition group-hover/bar:opacity-100">
{{ cat.pct }}%
</span>
<!-- Vote count -->
<span class="min-w-[56px] text-right text-sm font-semibold text-slate-600">
{{ cat.votes.toLocaleString('de-DE') }}
</span>
</div>
</div>
<div class="relative mt-2 h-2.5 overflow-hidden rounded-full bg-violet-100">
<div
class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#a78bfa)] transition-all duration-500"
:style="{ width: `${cat.barWidth}%` }"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="px-6 py-12 text-center text-sm text-slate-500">
Noch keine Stimmenverteilung vorhanden.
</div>
</Card>
<!-- Category health -->
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Status aller Kategorien</h2>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-2">
<NativeSelect
v-model="groupFilter"
class="w-44"
:options="groupOptions.map((group) => ({ label: group, value: group }))"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="opt in healthStatusOptions"
:key="opt.key"
type="button"
class="flex h-9 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition"
:class="healthFilter === opt.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="healthFilter = opt.key"
>
<span v-if="opt.dot" class="h-2 w-2 rounded-full" :class="opt.dot" />
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
<div v-if="filteredHealth.length > 0" class="divide-y divide-violet-50">
<RouterLink
v-for="category in filteredHealth"
:key="category.id"
to="/admin/categories"
class="grid items-center gap-3 px-5 py-3.5 transition hover:bg-violet-50/50 sm:grid-cols-[minmax(0,1fr)_80px_80px_100px_140px]"
>
<!-- Name + group -->
<span class="min-w-0">
<span class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" :class="category.statusDot" />
<strong class="truncate text-sm text-slate-900">{{ category.name }}</strong>
</span>
<span class="mt-0.5 block truncate text-xs text-slate-500 pl-4">{{ category.groupName || 'Ohne Gruppe' }}</span>
</span>
<!-- Candidates -->
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.candidates }}</strong>
Kandidaten
</span>
<!-- Reviews -->
<span class="text-sm" :class="category.reviews > 0 ? 'text-amber-600 font-semibold' : 'text-slate-500'">
<strong class="block" :class="category.reviews > 0 ? 'text-amber-700' : 'text-slate-900'">{{ category.reviews }}</strong>
Reviews
</span>
<!-- Votes + pct -->
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
<span class="text-violet-600 font-semibold">{{ category.votePct }}%</span>
</span>
<!-- Status badge -->
<span class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
<CheckCircle2 v-if="category.status === 'Gewinner gesetzt'" class="h-3 w-3" />
{{ category.status }}
</span>
</RouterLink>
</div>
<div v-else class="px-5 py-12 text-center text-sm text-slate-500">
Keine Kategorien für diesen Filter.
</div>
<div class="flex items-center gap-2 border-t border-violet-100 bg-emerald-50/60 px-5 py-3 text-sm text-emerald-800">
<CheckCircle2 class="h-4 w-4 shrink-0 text-emerald-500" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
<RouterLink to="/admin/categories" class="ml-auto flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800">
Kategorien bearbeiten <ExternalLink class="h-3 w-3" />
</RouterLink>
</div>
</Card>
</div>
</template>
@@ -8,6 +8,7 @@ import AdminCandidatesTable from '../../components/admin/AdminCandidatesTable.vu
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminCandidateManager } from '../../components/admin/useAdminCandidateManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Card from '../../components/ui/Card.vue'
const {
@@ -43,6 +44,8 @@ const {
saveModal,
confirmDelete,
} = useAdminCandidateManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -95,9 +98,6 @@ const {
@open-create="openCreate"
/>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<AdminCandidatesTable
:paged-candidates="pagedCandidates"
:total-count="seasonDetail.candidates.length"
@@ -4,6 +4,7 @@ import { Layers3, PlusCircle, Search, Tags, Trash2, TriangleAlert } from '@lucid
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminCategoryManager } from '../../components/admin/useAdminCategoryManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
@@ -29,6 +30,8 @@ const {
fillNewSlug,
confirmDeleteCategory,
} = useAdminCategoryManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -108,9 +111,6 @@ const {
</div>
</div>
<p v-if="adminMessage" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
@@ -0,0 +1,462 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
ChevronRight,
ExternalLink,
Layers3,
PlusCircle,
Search,
Tags,
Trash2,
TriangleAlert,
Users,
X,
} from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminCategoryManager } from '../../components/admin/useAdminCategoryManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
const {
selectedSeasonId,
query,
statusFilter,
selectedCategoryId,
saving,
adminMessage,
adminError,
editForms,
newCategoryForm,
filteredCategories,
selectedCategory,
categoryStats,
statusFilters,
categoryToDelete,
deleting,
saveCategory,
createCategory,
fillNewSlug,
confirmDeleteCategory,
} = useAdminCategoryManager()
watchAdminToast(adminMessage, adminError)
// ---------- "Neu" panel toggle ----------
const showCreatePanel = ref(false)
// ---------- Grouped list ----------
const groupedCategories = computed(() => {
const groups = new Map<string, typeof filteredCategories.value>()
for (const cat of filteredCategories.value) {
const key = cat.groupName || 'Ohne Gruppe'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(cat)
}
return [...groups.entries()].map(([name, cats]) => ({ name, cats }))
})
// ---------- Stats für aktuell gewählte Kategorie ----------
const selectedCandidateCount = computed(() =>
selectedCategory.value
? filteredCategories.value.find((c) => c.id === selectedCategory.value!.id)?.candidates ?? 0
: 0,
)
const selectedPendingCount = computed(() =>
selectedCategory.value
? filteredCategories.value.find((c) => c.id === selectedCategory.value!.id)?.pending ?? 0
: 0,
)
// ---------- Top-level stats ----------
const topStats = computed(() => [
{
label: 'Kategorien',
value: categoryStats.value.find((s) => s.label === 'Kategorien')?.value ?? 0,
tone: 'text-violet-700 bg-violet-50 border-violet-100',
},
{
label: 'Kandidaten',
value: categoryStats.value.find((s) => s.label === 'Kandidaten')?.value ?? 0,
tone: 'text-cyan-700 bg-cyan-50 border-cyan-100',
},
{
label: 'Offene Reviews',
value: categoryStats.value.find((s) => s.label === 'Reviews')?.value ?? 0,
tone: 'text-amber-700 bg-amber-50 border-amber-100',
},
{
label: 'Leer',
value: statusFilters.value.find((f) => f.key === 'empty')?.count ?? 0,
tone: 'text-rose-700 bg-rose-50 border-rose-100',
},
])
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Kategorien"
description="Kategorien verwalten, Gruppen pflegen und Kandidatenbasis prüfen."
:icon="Tags"
/>
<AdminSeasonToolbar />
<!-- Top stats -->
<section class="grid grid-cols-2 gap-4 xl:grid-cols-4">
<Card v-for="stat in topStats" :key="stat.label" class="flex items-center gap-4 p-5">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl border text-sm" :class="stat.tone">
<span class="text-lg font-black">{{ stat.value }}</span>
</span>
<span class="text-sm font-semibold text-slate-600">{{ stat.label }}</span>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(340px,0.82fr)_minmax(0,1.18fr)]">
<!-- Linke Spalte: Gruppierte Liste -->
<div class="flex flex-col gap-3">
<Card class="overflow-hidden">
<!-- Search + Filter -->
<div class="space-y-3 border-b border-violet-100 p-4">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="query"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Kategorie, Gruppe oder Slug"
/>
</label>
<div class="flex flex-wrap gap-1.5">
<button
v-for="filter in statusFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-1 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>
<!-- Grouped category list -->
<div class="max-h-[640px] overflow-y-auto">
<template v-if="groupedCategories.length > 0">
<div v-for="group in groupedCategories" :key="group.name">
<!-- Group header -->
<div class="sticky top-0 z-10 flex items-center gap-2 border-b border-violet-50 bg-violet-50/90 px-4 py-2 backdrop-blur-sm">
<Layers3 class="h-3.5 w-3.5 text-violet-400" />
<span class="text-[11px] font-bold uppercase tracking-[0.18em] text-violet-500">{{ group.name }}</span>
<span class="ml-auto rounded-full border border-violet-100 bg-white px-2 py-0.5 text-[10px] font-semibold text-violet-600">{{ group.cats.length }}</span>
</div>
<!-- Categories in group -->
<div class="divide-y divide-violet-50/80">
<button
v-for="cat in group.cats"
:key="cat.id"
type="button"
class="group w-full px-4 py-3 text-left transition"
:class="selectedCategory?.id === cat.id
? 'bg-[linear-gradient(135deg,#ece2ff30,#fff2dd20)] outline-none ring-1 ring-inset ring-violet-200'
: 'hover:bg-violet-50/50'"
@click="selectedCategoryId = cat.id; showCreatePanel = false"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate text-sm font-semibold text-slate-900">{{ cat.name }}</p>
<ChevronRight
class="h-3.5 w-3.5 shrink-0 text-violet-400 opacity-0 transition group-hover:opacity-100"
:class="selectedCategory?.id === cat.id ? 'opacity-100' : ''"
/>
</div>
<p class="mt-0.5 truncate text-xs text-slate-400">/{{ cat.slug }}</p>
<!-- Badges -->
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="cat.candidates > 0 ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600'"
>
{{ cat.candidates }} Kandidaten
</span>
<span v-if="cat.pending > 0" class="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700">
{{ cat.pending }} Reviews
</span>
<span class="rounded-full bg-slate-50 px-2 py-0.5 text-[10px] font-semibold text-slate-500">
Limit {{ cat.maxNomineesPerUser }}
</span>
</div>
</div>
<span class="mt-0.5 shrink-0 rounded-xl border border-violet-100 bg-white px-2 py-0.5 text-[11px] font-bold text-violet-600">
#{{ cat.sortOrder }}
</span>
</div>
</button>
</div>
</div>
</template>
<p v-else class="px-5 py-10 text-center text-sm text-slate-500">
Keine Kategorien passen zu diesem Filter.
</p>
</div>
<!-- Add button -->
<div class="border-t border-violet-100 p-3">
<button
type="button"
class="flex w-full items-center justify-center gap-2 rounded-2xl border py-2.5 text-sm font-semibold transition"
:class="showCreatePanel
? 'border-violet-200 bg-violet-100 text-violet-800'
: 'border-violet-100 bg-white text-violet-700 hover:bg-violet-50'"
@click="showCreatePanel = !showCreatePanel; selectedCategoryId = null"
>
<component :is="showCreatePanel ? X : PlusCircle" class="h-4 w-4" />
{{ showCreatePanel ? 'Abbrechen' : 'Kategorie anlegen' }}
</button>
</div>
</Card>
</div>
<!-- Rechte Spalte: Edit / Create -->
<div class="space-y-0">
<!-- Create panel -->
<Card v-if="showCreatePanel" class="p-6">
<div class="flex items-start gap-4">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<PlusCircle class="h-5 w-5" />
</span>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neue Kategorie</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Kategorie anlegen</h2>
</div>
</div>
<div class="mt-5 grid gap-4 sm:grid-cols-2">
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
<input
v-model="newCategoryForm.groupName"
placeholder="z. B. Streaming"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
v-model="newCategoryForm.name"
placeholder="Bester Streamer"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
<div class="flex gap-2">
<input
v-model="newCategoryForm.slug"
placeholder="bester-streamer"
class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 px-4 text-sm font-mono outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
<button
type="button"
class="h-12 rounded-2xl border border-violet-100 bg-violet-50 px-4 text-xs font-semibold text-violet-700 transition hover:bg-violet-100"
@click="fillNewSlug"
>
Auto
</button>
</div>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input
v-model.number="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"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
<input
v-model.number="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"
/>
</label>
</div>
</div>
<label class="mt-4 block space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea
v-model="newCategoryForm.description"
rows="3"
placeholder="Kurze Beschreibung für Team und Kandidaten-Seite"
class="w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<div class="mt-5 flex justify-end gap-3">
<Button variant="ghost" @click="showCreatePanel = false">Abbrechen</Button>
<Button :disabled="saving === 'new' || !selectedSeasonId" @click="createCategory">
{{ saving === 'new' ? 'Erstellt ' : 'Kategorie anlegen' }}
</Button>
</div>
</Card>
<!-- Edit panel -->
<Card v-else-if="selectedCategory && editForms[selectedCategory.id]" class="overflow-hidden">
<!-- Header -->
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] px-6 py-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">
{{ editForms[selectedCategory.id].groupName || 'Ohne Gruppe' }}
</p>
<h2 class="mt-1 truncate text-xl font-bold text-slate-900">{{ selectedCategory.name }}</h2>
<p class="mt-1 font-mono text-sm text-slate-400">/{{ selectedCategory.slug }}</p>
</div>
<span class="shrink-0 rounded-2xl border border-violet-200 bg-white px-3 py-2 text-sm font-bold text-violet-700">
#{{ selectedCategory.sortOrder }}
</span>
</div>
<!-- Quick stats row -->
<div class="mt-4 flex flex-wrap gap-3">
<RouterLink
to="/admin/candidates"
class="flex items-center gap-1.5 rounded-full border border-violet-100 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-violet-200 hover:bg-violet-50"
>
<Users class="h-3.5 w-3.5 text-violet-500" />
{{ selectedCandidateCount }} Kandidaten
<ExternalLink class="h-3 w-3 text-slate-400" />
</RouterLink>
<span
v-if="selectedPendingCount > 0"
class="flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-700"
>
{{ selectedPendingCount }} Reviews offen
</span>
<span
v-if="selectedCandidateCount === 0"
class="flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-600"
>
Keine Kandidaten
</span>
</div>
</div>
<!-- Form body -->
<div class="p-6">
<div class="grid gap-4 sm:grid-cols-2">
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
<input
v-model="editForms[selectedCategory.id].groupName"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
v-model="editForms[selectedCategory.id].name"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
<input
v-model="editForms[selectedCategory.id].slug"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 font-mono text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input
v-model.number="editForms[selectedCategory.id].sortOrder"
type="number"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
<input
v-model.number="editForms[selectedCategory.id].maxNomineesPerUser"
type="number"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
</div>
</div>
<label class="mt-4 block space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea
v-model="editForms[selectedCategory.id].description"
rows="3"
class="w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
</div>
<!-- Actions -->
<div class="flex items-center justify-between gap-3 border-t border-violet-100 px-6 py-4">
<Button
variant="ghost"
class="gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100"
@click="categoryToDelete = selectedCategory"
>
<Trash2 class="h-4 w-4" /> Löschen
</Button>
<Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
{{ saving === selectedCategory.id ? 'Speichert ' : 'Speichern' }}
</Button>
</div>
</Card>
<!-- Empty state when nothing selected -->
<Card v-else class="flex flex-col items-center justify-center gap-4 py-20 text-center">
<span class="grid h-14 w-14 place-items-center rounded-full bg-violet-100 text-violet-400">
<Tags class="h-7 w-7" />
</span>
<div>
<p class="font-semibold text-slate-700">Kategorie auswählen</p>
<p class="mt-1 text-sm text-slate-500">Links eine Kategorie anklicken oder eine neue anlegen.</p>
</div>
<Button class="gap-2 mt-2" @click="showCreatePanel = true; selectedCategoryId = null">
<PlusCircle class="h-4 w-4" /> Neue Kategorie
</Button>
</Card>
</div>
</section>
<!-- Delete modal -->
<Modal :open="!!categoryToDelete" title="Kategorie löschen?" @close="categoryToDelete = null">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ categoryToDelete?.name }}</strong>" und alle zugehörigen Kandidaten werden
aus diesem Award-Jahr entfernt. Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="categoryToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDeleteCategory">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</template>
</Modal>
</div>
</template>
+56 -9
View File
@@ -4,9 +4,12 @@ import { CheckCircle2, ExternalLink, Film, Search, Trash2, TriangleAlert, Undo2,
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminClipManager } from '../../components/admin/useAdminClipManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
import PaginationFooter from '../../components/ui/PaginationFooter.vue'
const {
query,
@@ -15,6 +18,7 @@ const {
categoryFilter,
deleting,
statusSaving,
bulkSaving,
adminMessage,
adminError,
clipToDelete,
@@ -22,6 +26,11 @@ const {
submissions,
categoryName,
clips,
page,
totalPages,
pagedClips,
rangeStart,
rangeEnd,
clipEmbeds,
stats,
statusFilters,
@@ -33,8 +42,11 @@ const {
duplicateUrlCount,
creatorClipCount,
updateClipStatus,
bulkUpdateStatus,
confirmDelete,
} = useAdminClipManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -71,12 +83,14 @@ const {
placeholder="Titel, Creator, Plattform, URL oder User suchen …"
/>
</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>
<NativeSelect
v-model="platformFilter"
:options="platformFilters.map((filter) => ({ label: `${filter.label} · ${filter.count}`, value: filter.key }))"
/>
<NativeSelect
v-model="categoryFilter"
:options="categoryFilters.map((filter) => ({ label: `${filter.label} · ${filter.count}`, value: filter.id }))"
/>
</div>
<div class="flex flex-wrap gap-2">
<button
@@ -92,11 +106,35 @@ const {
</div>
</div>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div v-if="clips.length > 0" class="flex flex-wrap items-center justify-between gap-3 border-b border-violet-100 bg-violet-50/40 px-5 py-3">
<p class="text-sm text-slate-600">
<strong class="text-violet-800">{{ clips.length }}</strong>
{{ clips.length === 1 ? 'Clip' : 'Clips' }} in dieser Ansicht
</p>
<div class="flex flex-wrap gap-2">
<Button
class="gap-1.5 !bg-emerald-600 hover:!bg-emerald-500"
size="sm"
:disabled="bulkSaving || statusSaving !== null"
@click="bulkUpdateStatus('approved')"
>
<CheckCircle2 class="h-3.5 w-3.5" />
{{ bulkSaving ? 'Verarbeite …' : 'Alle freigeben' }}
</Button>
<Button
class="gap-1.5 !bg-rose-600 hover:!bg-rose-500"
size="sm"
:disabled="bulkSaving || statusSaving !== null"
@click="bulkUpdateStatus('rejected')"
>
<XCircle class="h-3.5 w-3.5" />
Alle ablehnen
</Button>
</div>
</div>
<div class="divide-y divide-violet-50">
<div v-for="clip in clips" :key="clip.id" class="grid gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)_320px] xl:items-start">
<div v-for="clip in pagedClips" :key="clip.id" class="grid gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)_320px] xl:items-start">
<div class="flex min-w-0 gap-4">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] text-violet-600">
<Film class="h-5 w-5" />
@@ -186,6 +224,15 @@ const {
</p>
</div>
</div>
<PaginationFooter
:page="page"
:total-pages="totalPages"
:range-start="rangeStart"
:range-end="rangeEnd"
:filtered-count="clips.length"
@update:page="page = $event"
/>
</Card>
<Modal :open="!!clipToDelete" title="Clip entfernen?" @close="clipToDelete = null">
+129 -57
View File
@@ -1,27 +1,23 @@
<script setup lang="ts">
import { FileText } from '@lucide/vue'
import { FileText, Link2, MessageCircleQuestion, Share2, ShieldCheck } from '@lucide/vue'
import { computed, ref } from 'vue'
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
import AdminContentFaqPreviewModal from '../../components/admin/AdminContentFaqPreviewModal.vue'
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
import AdminContentFooterPreviewModal from '../../components/admin/AdminContentFooterPreviewModal.vue'
import AdminContentLinksSection from '../../components/admin/AdminContentLinksSection.vue'
import type { FooterPreviewKey } from '../../components/admin/AdminContentLinksSection.vue'
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
import AdminContentSectionNav from '../../components/admin/AdminContentSectionNav.vue'
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Modal from '../../components/ui/Modal.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { privacyContentToHtml } from '../../lib/privacyContent'
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
const sectionLinks = [
{ href: '#content-basics', label: 'Basis & Host', primary: true },
{ href: '#content-links', label: 'Footer & Kontakt' },
{ href: '#content-socials', label: 'Social Links' },
{ href: '#content-faq', label: 'FAQ' },
{ href: '#content-privacy', label: 'Datenschutz' },
]
type ContentEditorKey = 'links' | 'socials' | 'faq' | 'privacy'
const {
form,
@@ -49,8 +45,12 @@ const {
adminSiteSettings,
} = useAdminContentManager()
watchAdminToast(saveMessage, saveError)
const footerPreviewOpen = ref(false)
const footerPreviewKey = ref<FooterPreviewKey>('imprint')
const faqPreviewOpen = ref(false)
const activeContentEditor = ref<ContentEditorKey | null>(null)
const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, url: string, content: string }>>(() => ({
imprint: {
@@ -72,11 +72,50 @@ const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, ur
const activeFooterPreview = computed(() => footerPreviewPages.value[footerPreviewKey.value])
const activeFooterPreviewHtml = computed(() => privacyContentToHtml(activeFooterPreview.value.content))
const contentEditors = computed(() => [
{
key: 'links' as const,
eyebrow: 'Footer & Kontakt',
title: 'Rechtliche Links',
description: 'Kontaktwege, Impressum und Sponsoren-Inhalte bearbeiten.',
metric: `${[form.contactUrl, form.imprintUrl, form.sponsorsUrl].filter(Boolean).length}/3 URLs`,
icon: Link2,
},
{
key: 'socials' as const,
eyebrow: 'Community',
title: 'Social Links',
description: 'Plattformen, Labels, URLs und Custom Icons pflegen.',
metric: `${form.socialLinks.length} Links`,
icon: Share2,
},
{
key: 'faq' as const,
eyebrow: 'Support',
title: 'FAQ',
description: 'Fragen und Antworten für die Landingpage verwalten.',
metric: `${form.faq.length} Fragen`,
icon: MessageCircleQuestion,
},
{
key: 'privacy' as const,
eyebrow: 'Rechtstexte',
title: 'Datenschutz',
description: 'Datenschutzerklärung mit Preview bearbeiten.',
metric: privacyUpdatedLabel.value,
icon: ShieldCheck,
},
])
const currentContentEditor = computed(() => contentEditors.value.find((item) => item.key === activeContentEditor.value) ?? null)
function openFooterPreview(key: FooterPreviewKey) {
footerPreviewKey.value = key
footerPreviewOpen.value = true
}
function openContentEditor(key: ContentEditorKey) {
activeContentEditor.value = key
}
</script>
<template>
@@ -87,59 +126,87 @@ function openFooterPreview(key: FooterPreviewKey) {
:icon="FileText"
/>
<div class="space-y-3">
<p v-if="saveMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ saveMessage }}</p>
<p v-if="saveError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ saveError }}</p>
</div>
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<section class="grid gap-6 xl:grid-cols-[300px_minmax(0,1fr)]">
<aside class="space-y-4 xl:sticky xl:top-32 xl:self-start">
<AdminContentSectionNav :sections="sectionLinks" />
</aside>
<section aria-labelledby="content-editor-title" class="space-y-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Weitere Bereiche</p>
<h2 id="content-editor-title" class="mt-1 text-xl font-bold text-slate-900">Landingpage-Module bearbeiten</h2>
</div>
<div class="space-y-6">
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentLinksSection
:form="form"
:saving="saving"
:save-site-settings="saveSiteSettings"
@open-preview="openFooterPreview"
/>
<AdminContentSocialLinksSection
:form="form"
:saving="saving"
:icon-upload-error="iconUploadError"
:add-social-link="addSocialLink"
:remove-social-link="removeSocialLink"
:is-uploaded-icon="isUploadedIcon"
:selected-social-icon-value="selectedSocialIconValue"
:handle-social-icon-selection="handleSocialIconSelection"
:has-social-icon-preview="hasSocialIconPreview"
:social-icon-mode-label="socialIconModeLabel"
:social-simple-icon-path="socialSimpleIconPath"
:social-simple-icon-color="socialSimpleIconColor"
:handle-social-icon-upload="handleSocialIconUpload"
:clear-social-icon="clearSocialIcon"
:save-site-settings="saveSiteSettings"
/>
<AdminContentFaqSection
:form="form"
:saving="saving"
:add-faq-item="addFaqItem"
:remove-faq-item="removeFaqItem"
:save-site-settings="saveSiteSettings"
/>
<AdminContentPrivacySection
:form="form"
:saving="saving"
:updated-by="adminSiteSettings.privacyPolicyUpdatedBy"
:updated-label="privacyUpdatedLabel"
:save-site-settings="saveSiteSettings"
@open-preview="privacyPreviewOpen = true"
/>
<div class="grid gap-3 lg:grid-cols-2">
<button
v-for="editor in contentEditors"
:key="editor.key"
type="button"
class="group flex min-h-[132px] items-start gap-4 rounded-[24px] border border-violet-100 bg-white/86 p-5 text-left shadow-[0_16px_40px_rgba(124,92,255,0.07)] transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/70 hover:shadow-[0_20px_48px_rgba(124,92,255,0.12)]"
@click="openContentEditor(editor.key)"
>
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700 transition group-hover:bg-white">
<component :is="editor.icon" class="h-5 w-5" />
</span>
<span class="min-w-0 flex-1">
<span class="block text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-500">{{ editor.eyebrow }}</span>
<span class="mt-1 block text-lg font-bold text-slate-900">{{ editor.title }}</span>
<span class="mt-2 block text-sm leading-6 text-slate-500">{{ editor.description }}</span>
</span>
<span class="shrink-0 rounded-2xl border border-violet-100 bg-violet-50 px-3 py-2 text-xs font-bold text-violet-700">{{ editor.metric }}</span>
</button>
</div>
</section>
<Modal
:open="activeContentEditor !== null"
:title="currentContentEditor?.title"
:subtitle="currentContentEditor?.description"
size="xl"
@close="activeContentEditor = null"
>
<AdminContentLinksSection
v-if="activeContentEditor === 'links'"
:form="form"
:saving="saving"
:save-site-settings="saveSiteSettings"
@open-preview="openFooterPreview"
/>
<AdminContentSocialLinksSection
v-else-if="activeContentEditor === 'socials'"
:form="form"
:saving="saving"
:icon-upload-error="iconUploadError"
:add-social-link="addSocialLink"
:remove-social-link="removeSocialLink"
:is-uploaded-icon="isUploadedIcon"
:selected-social-icon-value="selectedSocialIconValue"
:handle-social-icon-selection="handleSocialIconSelection"
:has-social-icon-preview="hasSocialIconPreview"
:social-icon-mode-label="socialIconModeLabel"
:social-simple-icon-path="socialSimpleIconPath"
:social-simple-icon-color="socialSimpleIconColor"
:handle-social-icon-upload="handleSocialIconUpload"
:clear-social-icon="clearSocialIcon"
:save-site-settings="saveSiteSettings"
/>
<AdminContentFaqSection
v-else-if="activeContentEditor === 'faq'"
:form="form"
:saving="saving"
:add-faq-item="addFaqItem"
:remove-faq-item="removeFaqItem"
:save-site-settings="saveSiteSettings"
@open-preview="faqPreviewOpen = true"
/>
<AdminContentPrivacySection
v-else-if="activeContentEditor === 'privacy'"
:form="form"
:saving="saving"
:updated-by="adminSiteSettings.privacyPolicyUpdatedBy"
:updated-label="privacyUpdatedLabel"
:save-site-settings="saveSiteSettings"
@open-preview="privacyPreviewOpen = true"
/>
</Modal>
<AdminContentPrivacyPreviewModal
:open="privacyPreviewOpen"
:content-html="privacyPreviewHtml"
@@ -153,5 +220,10 @@ function openFooterPreview(key: FooterPreviewKey) {
:content-html="activeFooterPreviewHtml"
@close="footerPreviewOpen = false"
/>
<AdminContentFaqPreviewModal
:open="faqPreviewOpen"
:faq="form.faq"
@close="faqPreviewOpen = false"
/>
</div>
</template>
+117 -24
View File
@@ -9,14 +9,17 @@ import {
Film,
LayoutDashboard,
FileClock,
Menu,
Settings,
Tags,
Trophy,
UserCog,
Users,
FileText,
X,
} from '@lucide/vue'
import AdminToastViewport from '../../components/admin/AdminToastViewport.vue'
import Card from '../../components/ui/Card.vue'
import { getRiskMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
@@ -29,6 +32,7 @@ const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const pendingClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const adminWorkspaceLoading = ref(false)
const adminWorkspaceLoaded = ref(false)
const mobileNavOpen = ref(false)
const fullNavGroups = computed(() => [
{
@@ -76,16 +80,15 @@ const navGroups = computed(() => {
})
const currentSeason = computed(() => store.adminSeasonDetail)
const seasonSummary = computed(() => [
{ label: 'Kategorien', value: currentSeason.value.categories.length },
{ label: 'Kandidaten', value: currentSeason.value.candidates.length },
{ label: 'Reviews', value: currentSeason.value.pendingNominations.length },
])
function isActive(to: string) {
return route.path === to
}
// Close mobile nav on route change
watch(() => route.path, () => {
mobileNavOpen.value = false
})
async function ensureAdminWorkspace() {
if (!authStore.hydrated || !authStore.canAccessAdmin || adminWorkspaceLoading.value) return
if (adminWorkspaceLoaded.value && store.apiMode === 'api') return
@@ -113,13 +116,119 @@ watch(
</script>
<template>
<div class="pb-10">
<AdminToastViewport />
<!-- Mobile top bar -->
<div class="sticky top-0 z-40 flex items-center gap-3 border-b border-violet-100 bg-white/90 px-4 py-3 backdrop-blur-sm xl:hidden">
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-200 text-violet-600 transition hover:bg-violet-50"
aria-label="Navigation öffnen"
@click="mobileNavOpen = true"
>
<Menu class="h-5 w-5" />
</button>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-bold text-violet-900">
{{ currentSeason.year ? `Admin · ${currentSeason.year}` : 'Admin' }}
</p>
<p class="truncate text-xs text-slate-500">{{ currentSeason.currentPhase || currentSeason.name || 'Kein Jahr gewählt' }}</p>
</div>
<div v-if="store.adminSeasonDetail.pendingNominations.length > 0" class="shrink-0">
<span class="grid h-6 min-w-6 place-items-center rounded-full border border-violet-200 bg-violet-50 px-2 text-[11px] font-bold text-violet-700">
{{ store.adminSeasonDetail.pendingNominations.length }}
</span>
</div>
</div>
<!-- Mobile drawer (Teleport to body) -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="mobileNavOpen" class="fixed inset-0 z-50 xl:hidden" @click.self="mobileNavOpen = false">
<!-- Backdrop -->
<div class="absolute inset-0 bg-slate-950/40 backdrop-blur-sm" @click="mobileNavOpen = false" />
<!-- Drawer panel -->
<Transition
enter-active-class="transition duration-200"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-150"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full"
>
<div v-if="mobileNavOpen" class="absolute inset-y-0 left-0 flex w-72 flex-col gap-3 overflow-y-auto bg-white/98 p-3 shadow-2xl backdrop-blur-md">
<!-- Drawer header -->
<div class="flex items-center justify-between gap-3 px-1 py-1">
<div class="min-w-0">
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Admin</p>
<p class="truncate text-sm font-bold text-violet-900">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || '' }}</p>
</div>
<button
type="button"
class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-violet-200 text-violet-600 transition hover:bg-violet-50"
aria-label="Navigation schließen"
@click="mobileNavOpen = false"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Nav groups -->
<nav class="space-y-4">
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
<p class="px-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-400">{{ group.label }}</p>
<RouterLink
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="group block rounded-2xl border px-3 py-2.5 transition"
:class="isActive(item.to) ? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#f7eef8] text-violet-950 shadow-[0_14px_34px_rgba(168,145,214,0.14)]' : 'border-transparent bg-white/55 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
>
<div class="flex items-center gap-3">
<div
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
:class="isActive(item.to) ? 'bg-white text-violet-700 shadow-sm' : 'bg-violet-50 text-violet-600 group-hover:bg-white'"
>
<component :is="item.icon" class="h-4.5 w-4.5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center justify-between gap-3">
<p class="truncate text-sm font-semibold leading-5">{{ item.label }}</p>
<span
v-if="item.badge()"
class="grid h-6 min-w-6 shrink-0 place-items-center rounded-full border border-violet-200 bg-white px-2 text-[11px] font-semibold text-violet-700 shadow-sm"
>
{{ item.badge() }}
</span>
</div>
<p class="mt-0.5 truncate text-xs leading-5 text-slate-500">{{ item.description }}</p>
</div>
</div>
</RouterLink>
</section>
</nav>
</div>
</Transition>
</div>
</Transition>
</Teleport>
<div class="admin-panel pb-10 pt-4 xl:pt-0">
<div class="grid gap-6 xl:grid-cols-[292px_minmax(0,1fr)]">
<main class="order-1 min-w-0 xl:order-2">
<RouterView />
</main>
<aside class="order-2 space-y-3 xl:order-1 xl:sticky xl:top-4 xl:h-fit">
<!-- Desktop sidebar (hidden on mobile) -->
<aside class="order-2 hidden space-y-3 xl:order-1 xl:block xl:sticky xl:top-4 xl:h-fit">
<Card class="p-3">
<nav class="space-y-4">
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
@@ -155,22 +264,6 @@ watch(
</section>
</nav>
</Card>
<Card class="p-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Aktives Jahr</p>
<p class="mt-1 truncate text-sm font-semibold text-violet-800">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || 'Kein Status' }}</p>
<p class="mt-1 truncate text-xs text-slate-500">{{ currentSeason.name || 'Bitte Jahr auswählen.' }}</p>
<div class="mt-3 grid grid-cols-3 gap-1.5">
<div
v-for="item in seasonSummary"
:key="item.label"
class="rounded-md border border-white/80 bg-white/75 px-2 py-1.5"
>
<p class="truncate text-[9px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ item.label }}</p>
<strong class="block text-base leading-5 text-violet-800">{{ item.value }}</strong>
</div>
</div>
</Card>
</aside>
</div>
</div>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ClipboardList, Search, Sparkles, Tags, Users } from '@lucide/vue'
import { ClipboardList, Search, Sparkles, Tags, Users, XCircle } from '@lucide/vue'
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
import AdminNominationReviewModal from '../../components/admin/AdminNominationReviewModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import PaginationFooter from '../../components/ui/PaginationFooter.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
@@ -72,6 +73,41 @@ function openReviewModal(nominationId?: number) {
void router.replace({ name: 'admin-nominations', query: nextQuery })
}
const bulkRejecting = ref<number | null>(null)
async function bulkRejectCategory(categoryId: number) {
if (!store.adminSelectedSeasonId || bulkRejecting.value !== null) return
const ids = seasonDetail.value.pendingNominations
.filter((n) => n.categoryId === categoryId)
.map((n) => n.id)
if (ids.length === 0) return
bulkRejecting.value = categoryId
try {
await store.bulkRejectAdminNominations(ids, store.adminSelectedSeasonId)
} finally {
bulkRejecting.value = null
categoryFilter.value = null
}
}
const NOM_PAGE_SIZE = 20
const page = ref(1)
const totalPages = computed(() => Math.max(1, Math.ceil(filteredNominations.value.length / NOM_PAGE_SIZE)))
const pagedNominations = computed(() =>
filteredNominations.value.slice((page.value - 1) * NOM_PAGE_SIZE, page.value * NOM_PAGE_SIZE),
)
const rangeStart = computed(() =>
filteredNominations.value.length === 0 ? 0 : (page.value - 1) * NOM_PAGE_SIZE + 1,
)
const rangeEnd = computed(() => Math.min(page.value * NOM_PAGE_SIZE, filteredNominations.value.length))
watch([query, categoryFilter, statusFilter], () => {
page.value = 1
})
watch(totalPages, (max) => {
if (page.value > max) page.value = max
})
function closeReviewModal() {
const restQuery: LocationQueryRaw = { ...route.query }
delete restQuery.review
@@ -151,20 +187,35 @@ watch(
<h2 class="mt-1 text-xl font-bold text-slate-900">Wo staut es sich?</h2>
</div>
<div class="divide-y divide-violet-50">
<button
<div
v-for="category in categoryStats"
:key="category.id"
type="button"
class="grid w-full grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4 text-left transition hover:bg-violet-50/50"
class="group/cat grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 transition hover:bg-violet-50/50"
:class="categoryFilter === category.id ? 'bg-violet-50/80' : ''"
@click="categoryFilter = categoryFilter === category.id ? null : category.id"
>
<span class="min-w-0">
<button
type="button"
class="min-w-0 text-left"
@click="categoryFilter = categoryFilter === category.id ? null : category.id"
>
<span class="block truncate font-semibold text-slate-900">{{ category.name }}</span>
<span class="mt-1 block truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidates }} Kandidaten</span>
</span>
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-sm font-semibold text-violet-800">{{ category.pending }}</span>
</button>
<span class="mt-0.5 block truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidates }} Kandidaten</span>
</button>
<div class="flex shrink-0 items-center gap-2">
<button
v-if="category.pending > 0"
type="button"
class="hidden h-7 items-center gap-1 rounded-full border border-rose-200 bg-white px-2.5 text-[11px] font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50 group-hover/cat:flex"
:disabled="bulkRejecting === category.id"
:title="`Alle ${category.pending} Nominierungen ablehnen`"
@click.stop="bulkRejectCategory(category.id)"
>
<XCircle class="h-3 w-3" />
{{ bulkRejecting === category.id ? '…' : 'Alle ablehnen' }}
</button>
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-sm font-semibold text-violet-800">{{ category.pending }}</span>
</div>
</div>
</div>
</Card>
@@ -201,8 +252,8 @@ watch(
</div>
</div>
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
<div v-for="nomination in filteredNominations" :key="nomination.id" class="px-5 py-4">
<div class="divide-y divide-violet-50">
<div v-for="nomination in pagedNominations" :key="nomination.id" class="px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ nomination.categoryName }}</p>
@@ -233,6 +284,15 @@ watch(
Keine Nominierungen passen zum aktuellen Filter.
</p>
</div>
<PaginationFooter
:page="page"
:total-pages="totalPages"
:range-start="rangeStart"
:range-end="rangeEnd"
:filtered-count="filteredNominations.length"
@update:page="page = $event"
/>
</Card>
</section>
@@ -9,6 +9,7 @@ import AdminReviewsQueueList from '../../components/admin/AdminReviewsQueueList.
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { useAdminReviewsManager } from '../../components/admin/useAdminReviewsManager'
import { watchAdminToast } from '../../composables/useAdminToast'
const {
reviewSaving,
@@ -32,6 +33,8 @@ const {
selectedPlatformValue,
handlePlatformSelection,
} = useAdminReviewsManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -55,9 +58,6 @@ const {
/>
<div class="space-y-4 p-6">
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="grid gap-5 xl:grid-cols-[minmax(320px,0.85fr)_minmax(0,1.15fr)]">
<AdminReviewsQueueList
:nominations="filteredNominations"
+67 -4
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ShieldAlert } from '@lucide/vue'
import { FileClock, ShieldAlert, SlidersHorizontal } from '@lucide/vue'
import { computed, ref } from 'vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminRiskDecisionPanel from '../../components/admin/AdminRiskDecisionPanel.vue'
@@ -8,7 +9,11 @@ import AdminRiskOverviewBoard from '../../components/admin/AdminRiskOverviewBoar
import AdminRiskQueueList from '../../components/admin/AdminRiskQueueList.vue'
import AdminRiskRulesEditor from '../../components/admin/AdminRiskRulesEditor.vue'
import { useAdminRiskManager } from '../../components/admin/useAdminRiskManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
type RiskModalKey = 'rules' | 'history'
const {
riskSaving,
@@ -59,6 +64,29 @@ const {
updateRiskRule,
saveRiskRules,
} = useAdminRiskManager()
const activeRiskModal = ref<RiskModalKey | null>(null)
const riskModalSummaries = computed(() => [
{
key: 'rules' as const,
eyebrow: 'Regeln',
title: 'Regel-Editor',
description: 'Thresholds, Zeitfenster und Severity zentral anpassen.',
metric: riskRulesLoading.value ? 'lädt' : `${riskRules.value.length} Regeln`,
icon: SlidersHorizontal,
},
{
key: 'history' as const,
eyebrow: 'Protokoll',
title: 'Entscheidungsprotokoll',
description: 'Entschiedene Hinweise prüfen und bei Bedarf wieder öffnen.',
metric: `${riskHistory.value.length} Einträge`,
icon: FileClock,
},
])
const currentRiskModal = computed(() => riskModalSummaries.value.find((item) => item.key === activeRiskModal.value) ?? null)
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -77,8 +105,6 @@ const {
:total-open="riskFlags.length"
:loaded-label="riskLoadedLabel"
:loading="riskLoading"
:message="adminMessage"
:error="adminError"
:stats="riskStats"
:severity-filters="severityFilters"
@refresh="loadRiskFlags"
@@ -117,7 +143,43 @@ const {
/>
</div>
<section class="border-t border-violet-100 bg-violet-50/25 p-6" aria-labelledby="risk-tools-title">
<div class="flex flex-col gap-1">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Werkzeuge</p>
<h2 id="risk-tools-title" class="text-lg font-bold text-slate-900">Regeln und Protokolle</h2>
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-2">
<button
v-for="item in riskModalSummaries"
:key="item.key"
type="button"
class="group flex min-h-[118px] items-start gap-4 rounded-[24px] border border-violet-100 bg-white/88 p-5 text-left shadow-[0_14px_36px_rgba(124,92,255,0.07)] transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-white hover:shadow-[0_18px_44px_rgba(124,92,255,0.12)]"
@click="activeRiskModal = item.key"
>
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700 transition group-hover:bg-violet-50">
<component :is="item.icon" class="h-5 w-5" />
</span>
<span class="min-w-0 flex-1">
<span class="block text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-500">{{ item.eyebrow }}</span>
<span class="mt-1 block text-lg font-bold text-slate-900">{{ item.title }}</span>
<span class="mt-2 block text-sm leading-6 text-slate-500">{{ item.description }}</span>
</span>
<span class="shrink-0 rounded-2xl border border-violet-100 bg-violet-50 px-3 py-2 text-xs font-bold text-violet-700">{{ item.metric }}</span>
</button>
</div>
</section>
</Card>
<Modal
:open="activeRiskModal !== null"
:title="currentRiskModal?.title"
:subtitle="currentRiskModal?.description"
size="xl"
@close="activeRiskModal = null"
>
<AdminRiskRulesEditor
v-if="activeRiskModal === 'rules'"
:rules="riskRules"
:loading="riskRulesLoading"
:saving="riskRulesSaving"
@@ -126,6 +188,7 @@ const {
/>
<AdminRiskHistorySection
v-else-if="activeRiskModal === 'history'"
v-model:history-status-filter="historyStatusFilter"
:risk-history="recentRiskHistory"
:total-history="riskHistory.length"
@@ -139,6 +202,6 @@ const {
@decide="updateRiskFlagStatus"
@page="setHistoryPage"
/>
</Card>
</Modal>
</div>
</template>
+549 -52
View File
@@ -1,14 +1,37 @@
<script setup lang="ts">
import { CalendarCog } from '@lucide/vue'
import { computed, reactive, ref } from 'vue'
import {
AlertTriangle,
CalendarCog,
CheckCircle2,
Clock,
ExternalLink,
Globe2,
History,
LockKeyhole,
Pencil,
PlusCircle,
Trash2,
WandSparkles,
X,
} from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminAwardYearsPanel from '../../components/admin/AdminAwardYearsPanel.vue'
import AdminSeasonCreateModal from '../../components/admin/AdminSeasonCreateModal.vue'
import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonPhaseSwitcher from '../../components/admin/AdminSeasonPhaseSwitcher.vue'
import AdminSeasonStatusCard from '../../components/admin/AdminSeasonStatusCard.vue'
import AdminSeasonTimelineEditor from '../../components/admin/AdminSeasonTimelineEditor.vue'
import {
createSeasonTimelineRows,
normalizePhaseKey,
resolveAutoPhase,
SEASON_PHASES,
type PhaseKey,
type PhaseRowConfig,
} from '../../components/admin/adminSeasonTimeline'
import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
const {
store,
@@ -24,14 +47,13 @@ const {
deleting,
seasonDetail,
selectedSeasonId,
selectedSeason,
readinessItems,
archiveReadinessIssues,
createPublicReadinessIssues,
canActivatePublic,
phasePresets,
canDeleteSelectedSeason,
canCompleteSelectedSeason,
createPublicReadinessIssues,
copySourceOptions,
loadingSeasonAudit,
latestSeasonAuditSummary,
@@ -46,66 +68,541 @@ const {
openDeleteSeasonModal,
confirmDeleteSeason,
} = useAdminSeasonManager()
watchAdminToast(adminMessage, adminError)
// ---------- Season switching ----------
const switchingSeasonId = ref<number | null>(null)
async function selectSeason(seasonId: number) {
if (seasonId === selectedSeasonId.value || switchingSeasonId.value) return
switchingSeasonId.value = seasonId
try {
await store.loadAdminSeasonDetail(seasonId)
} finally {
switchingSeasonId.value = null
}
}
// ---------- Phase / Timeline ----------
const currentPhaseKey = computed(() => normalizePhaseKey(form.currentPhase))
const autoPhase = computed(() => resolveAutoPhase(form))
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
const autoMismatch = computed(() => Boolean(autoPhase.value && normalizePhaseKey(autoPhase.value) !== currentPhaseKey.value))
const editingPhase = ref<PhaseKey | null>(null)
const timelineError = ref('')
const draft = reactive({ start: '', end: '', showStartsAt: '20:00' })
const timelineRows = computed(() =>
createSeasonTimelineRows(form, currentPhaseKey.value, autoPhase.value ?? '', editingPhase.value).map((row) => ({
...row,
finalLocked: row.key === 'completed' && !row.active,
})),
)
function startEdit(row: PhaseRowConfig) {
if (!row.editable) return
timelineError.value = ''
editingPhase.value = row.key
draft.start = row.start ? form[row.start] : ''
draft.end = row.end ? form[row.end] : ''
draft.showStartsAt = form.showStartsAt || '20:00'
}
function cancelEdit() {
editingPhase.value = null
timelineError.value = ''
}
async function saveEdit(row: PhaseRowConfig) {
timelineError.value = ''
if (!row.start || !row.end) return
if (!draft.start || !draft.end) { timelineError.value = 'Start und Ende ausfüllen.'; return }
if (row.key !== 'show' && draft.start > draft.end) { timelineError.value = 'Startdatum darf nicht nach Enddatum liegen.'; return }
form[row.start] = draft.start
form[row.end] = row.key === 'show' ? draft.start : draft.end
if (row.key === 'show') form.showStartsAt = draft.showStartsAt || '20:00'
const saved = await saveSeason()
if (saved !== false) editingPhase.value = null
}
// ---------- Countdown helpers ----------
function daysUntil(dateStr: string | undefined | null): number | null {
if (!dateStr) return null
const target = new Date(`${dateStr}T00:00:00`)
if (Number.isNaN(target.getTime())) return null
const diff = Math.ceil((target.getTime() - Date.now()) / 86_400_000)
return diff
}
function phaseCountdown(row: (typeof timelineRows.value)[number]): string | null {
if (row.active) {
const endField = SEASON_PHASES.find((p) => p.key === row.key)?.end
if (!endField) return null
const days = daysUntil(form[endField])
if (days === null) return null
if (days < 0) return 'Überfällig'
if (days === 0) return 'Heute endet'
if (days === 1) return 'Noch 1 Tag'
return `Noch ${days} Tage`
}
const startField = SEASON_PHASES.find((p) => p.key === row.key)?.start
if (!startField) return null
const days = daysUntil(form[startField])
if (days === null || days <= 0) return null
return `Startet in ${days} Tagen`
}
// ---------- Quick stats ----------
const quickStats = computed(() => {
const d = seasonDetail.value
const pendingClips = d.clipSubmissions.filter((c) => c.status === 'pending').length
return [
{ label: 'Kategorien', value: d.categories.length },
{ label: 'Kandidaten', value: d.candidates.length },
{ label: 'Nominierungen', value: d.pendingNominations.length, warn: d.pendingNominations.length > 0 },
{ label: 'Clips offen', value: pendingClips, warn: pendingClips > 0 },
{ label: 'Gewinner', value: d.results.length },
]
})
// ---------- Readiness score ----------
const readinessScore = computed(() => {
const done = readinessItems.value.filter((i) => i.complete).length
return { done, total: readinessItems.value.length }
})
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Jahre"
description="Jahr, Phase und öffentliche Sichtbarkeit steuern."
description="Award-Jahr wählen, Phase steuern, Timeline pflegen."
:icon="CalendarCog"
/>
<section class="space-y-6">
<AdminSeasonPhaseSwitcher
:form="form"
:season-name="seasonDetail.name"
:saving="saving"
:selected-season-id="selectedSeasonId"
:activate-phase="activatePhase"
/>
<!-- Jahr-Auswahl -->
<Card class="overflow-hidden">
<div class="flex items-center justify-between gap-3 border-b border-violet-100 px-5 py-3.5">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Award-Jahre</p>
<Button variant="ghost" size="sm" class="gap-1.5 border border-violet-100 text-violet-700 hover:bg-violet-50" @click="openCreateModal">
<PlusCircle class="h-4 w-4" />
Neu
</Button>
</div>
<div class="flex gap-2 overflow-x-auto px-5 py-4">
<button
v-for="season in store.adminSeasons"
:key="season.id"
type="button"
class="flex shrink-0 flex-col gap-1.5 rounded-[20px] border px-4 py-3 text-left transition"
:class="season.id === selectedSeasonId
? 'border-violet-300 bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] shadow-[0_12px_28px_rgba(139,108,219,0.18)]'
: 'border-violet-100 bg-white/80 hover:border-violet-200 hover:bg-violet-50/60'"
@click="selectSeason(season.id)"
>
<div class="flex items-center gap-2">
<strong class="text-2xl leading-none" :class="season.id === selectedSeasonId ? 'text-violet-900' : 'text-slate-700'">
{{ season.year }}
</strong>
<span
class="rounded-full border px-2 py-0.5 text-[10px] font-bold"
:class="season.isCurrent
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-500'"
>
{{ season.isCurrent ? 'Public' : 'Intern' }}
</span>
</div>
<p class="max-w-[180px] truncate text-xs text-slate-500">{{ season.name }}</p>
<span class="rounded-full border border-violet-100 bg-white/80 px-2.5 py-0.5 text-[11px] font-semibold text-violet-700">
{{ season.currentPhase }}
</span>
</button>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<AdminSeasonStatusCard
:form="form"
:season-name="seasonDetail.name"
:saving="saving"
:completing="completing"
:admin-message="adminMessage"
:admin-error="adminError"
:readiness-items="readinessItems"
:archive-readiness-issues="archiveReadinessIssues"
:can-activate-public="canActivatePublic"
:loading-season-audit="loadingSeasonAudit"
:latest-season-audit-summary="latestSeasonAuditSummary"
:latest-season-audit-meta="latestSeasonAuditMeta"
:selected-season-id="selectedSeasonId"
:can-delete-selected-season="canDeleteSelectedSeason"
:can-complete-selected-season="canCompleteSelectedSeason"
:selected-season-is-current="selectedSeason?.isCurrent ?? false"
:open-delete-season-modal="openDeleteSeasonModal"
:activate-public-season="activatePublicSeason"
:save-season="saveSeason"
:complete-season="completeSeason"
/>
<button
v-if="store.adminSeasons.length === 0"
type="button"
class="flex shrink-0 items-center gap-2 rounded-[20px] border border-dashed border-violet-200 px-6 py-4 text-sm font-semibold text-violet-500 hover:bg-violet-50"
@click="openCreateModal"
>
<PlusCircle class="h-4 w-4" />
Erstes Jahr anlegen
</button>
</div>
</Card>
<AdminAwardYearsPanel
:seasons="store.adminSeasons"
:selected-season-id="selectedSeasonId"
:open-create-modal="openCreateModal"
:load-season-detail="store.loadAdminSeasonDetail"
/>
</section>
<!-- Aktives Jahr: Hero + Quick Stats + Actions -->
<Card v-if="selectedSeasonId" class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] px-6 py-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex items-end gap-4">
<strong class="text-5xl font-black leading-none text-slate-950">{{ form.year }}</strong>
<div class="mb-0.5">
<p class="text-lg font-bold leading-snug text-slate-800">{{ form.name || '' }}</p>
<p class="mt-1 text-sm text-slate-500">{{ form.showStreamUrl || 'Kein Stream-Link' }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="rounded-2xl border px-4 py-2 text-sm font-bold"
:class="form.isCurrent
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-100 text-slate-600'"
>
{{ form.isCurrent ? '🟢 Public aktiv' : '⚫ Intern' }}
</span>
<span class="rounded-2xl border border-violet-200 bg-violet-100 px-4 py-2 text-sm font-bold text-violet-800">
{{ form.currentPhase || 'Keine Phase' }}
</span>
</div>
</div>
</div>
<AdminSeasonTimelineEditor
:form="form"
:saving="saving"
:selected-season-id="selectedSeasonId"
:save-season="saveSeason"
/>
<!-- Quick stats -->
<div class="grid grid-cols-2 divide-x divide-y divide-violet-50 border-b border-violet-100 sm:grid-cols-5">
<div
v-for="stat in quickStats"
:key="stat.label"
class="px-5 py-4"
>
<p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="stat.warn ? 'text-amber-600' : 'text-slate-400'">{{ stat.label }}</p>
<strong class="mt-1 block text-2xl" :class="stat.warn ? 'text-amber-700' : 'text-slate-900'">{{ stat.value }}</strong>
</div>
</div>
<!-- Actions footer -->
<div class="flex flex-wrap items-center gap-3 px-5 py-4">
<Button
variant="ghost"
class="gap-1.5 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100"
:disabled="!canDeleteSelectedSeason"
@click="openDeleteSeasonModal"
>
<Trash2 class="h-4 w-4" /> Löschen
</Button>
<Button
variant="ghost"
class="gap-1.5 border border-emerald-100 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
:disabled="saving || form.isCurrent || !canActivatePublic"
@click="activatePublicSeason"
>
<Globe2 class="h-4 w-4" /> {{ saving ? 'Aktiviert …' : 'Public aktivieren' }}
</Button>
<Button
variant="ghost"
class="gap-1.5 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100"
:disabled="completing || !canCompleteSelectedSeason"
@click="completeSeason"
>
<CheckCircle2 class="h-4 w-4" /> {{ completing ? 'Schließt …' : 'Beenden' }}
</Button>
<Button class="gap-1.5 ml-auto" :disabled="saving" @click="saveSeason">
{{ saving ? 'Speichert ' : 'Speichern' }}
</Button>
</div>
</Card>
<!-- Grunddaten + Readiness -->
<section v-if="selectedSeasonId" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<!-- Grunddaten -->
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Grunddaten</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Stammdaten bearbeiten</h2>
<div class="mt-5 space-y-4">
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr</span>
<input
v-model.number="form.year"
type="number"
placeholder="2027"
:disabled="saving"
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 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
v-model="form.name"
type="text"
placeholder="VTuber Star Awards 2027"
:disabled="saving"
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 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
<input
v-model="form.showStreamUrl"
type="url"
placeholder="https://twitch.tv/jayuhime"
:disabled="saving"
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 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:opacity-70">
<input v-model="form.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="saving" />
<span>
<span class="block font-semibold text-slate-800">Community-only</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">Voting und Teilnahme über die Community.</span>
</span>
</label>
<label
class="flex cursor-pointer gap-3 rounded-[22px] border p-4 transition"
:class="form.isCurrent
? 'border-emerald-200 bg-emerald-50/60 hover:bg-emerald-50'
: 'border-violet-100 bg-violet-50/50 hover:bg-violet-50 has-disabled:opacity-70'"
>
<input
v-model="form.isCurrent"
type="checkbox"
class="mt-1 h-4 w-4 shrink-0 accent-violet-600"
:disabled="saving || (!form.isCurrent && !canActivatePublic)"
/>
<span>
<span class="block font-semibold text-slate-800">Public-Kontext</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">
{{ form.isCurrent ? 'Aktiv auf der Landingpage.' : canActivatePublic ? 'Kann aktiviert werden.' : 'Readiness-Blocker auflösen.' }}
</span>
</span>
</label>
</div>
<!-- Audit entry -->
<div class="flex items-start gap-3 rounded-[22px] border border-violet-100 bg-white p-4">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<History class="h-5 w-5" />
</span>
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Letzte Admin-Aktion</p>
<p class="mt-1 text-sm font-semibold leading-5 text-slate-800">
{{ loadingSeasonAudit ? 'Lädt Audit …' : latestSeasonAuditSummary }}
</p>
<p class="mt-0.5 text-xs text-slate-500">{{ latestSeasonAuditMeta }}</p>
</div>
</div>
</div>
</Card>
<!-- Readiness -->
<Card class="p-6">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Checkliste</h2>
</div>
<div
class="flex items-center gap-2 rounded-2xl border px-3 py-2 text-xs font-bold"
:class="canActivatePublic ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700'"
>
<span class="h-2 w-2 rounded-full" :class="canActivatePublic ? 'bg-emerald-500' : 'bg-rose-500'" />
{{ readinessScore.done }}/{{ readinessScore.total }} bereit
</div>
</div>
<!-- Readiness progress bar -->
<div class="mt-4">
<div class="h-2 overflow-hidden rounded-full bg-slate-100">
<div
class="h-full rounded-full transition-all duration-500"
:class="canActivatePublic ? 'bg-emerald-500' : 'bg-violet-500'"
:style="{ width: `${(readinessScore.done / readinessScore.total) * 100}%` }"
/>
</div>
</div>
<div class="mt-4 grid gap-2">
<RouterLink
v-for="item in readinessItems"
:key="item.label"
:to="item.to"
class="group flex items-start gap-3 rounded-2xl border px-4 py-3 transition hover:shadow-sm"
:class="item.complete
? 'border-emerald-100 bg-emerald-50/60 hover:bg-emerald-50'
: item.blocking
? 'border-rose-200 bg-rose-50/60 hover:bg-rose-50'
: 'border-amber-100 bg-amber-50/60 hover:bg-amber-50'"
>
<CheckCircle2 v-if="item.complete" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<AlertTriangle v-else class="mt-0.5 h-4 w-4 shrink-0" :class="item.blocking ? 'text-rose-600' : 'text-amber-600'" />
<span class="min-w-0 flex-1">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-800">
{{ item.label }}
<span v-if="item.blocking && !item.complete" class="rounded-full bg-rose-100 px-2 py-0.5 text-[10px] font-bold text-rose-700">Blocker</span>
</span>
<span class="mt-0.5 block text-xs leading-5 text-slate-500">{{ item.note }}</span>
</span>
<ExternalLink class="mt-1 h-3.5 w-3.5 shrink-0 text-slate-400 opacity-0 transition group-hover:opacity-100" />
</RouterLink>
</div>
<div v-if="archiveReadinessIssues.length > 0" class="mt-4 rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700">
<strong class="block text-rose-800">Abschluss blockiert.</strong>
{{ archiveReadinessIssues.join(' ') }}
</div>
</Card>
</section>
<!-- Phase & Timeline (kombiniert) -->
<Card v-if="selectedSeasonId" class="overflow-hidden">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] px-6 py-5">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Phase & Timeline</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Phasenwechsel und Zeitplan</h2>
<p class="mt-1.5 max-w-2xl text-sm leading-5 text-slate-500">
Aktive Phase wechseln und Zeitfenster direkt inline bearbeiten. Die Daten steuern Public-API, Gates und Countdown.
</p>
</div>
<!-- Auto-mismatch compact banner -->
<div v-if="autoMismatch" class="flex items-center gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<AlertTriangle class="h-4 w-4 shrink-0 text-amber-700" />
<div>
<p class="text-sm font-bold text-amber-900">Zeitplan schlägt vor: {{ autoPhase }}</p>
</div>
<Button
variant="ghost"
size="sm"
class="ml-2 border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
:disabled="saving || !selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
@click="autoPhase && !autoPhaseIsCompleted && activatePhase(autoPhase)"
>
{{ autoPhaseIsCompleted ? 'Über Beenden' : 'Aktivieren' }}
</Button>
</div>
</div>
<p v-if="timelineError" class="border-b border-rose-100 bg-rose-50 px-6 py-3 text-sm font-semibold text-rose-700">{{ timelineError }}</p>
<!-- Phase cards grid -->
<div class="p-5">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<article
v-for="row in timelineRows"
:key="row.key"
class="flex flex-col rounded-[22px] border transition"
:class="row.active
? 'border-violet-300 bg-[linear-gradient(135deg,#ede9fe,#fff)] shadow-[0_16px_40px_rgba(139,108,219,0.16)]'
: row.isEditing
? 'border-violet-200 bg-violet-50/70'
: row.statusLabel === 'Auto'
? 'border-amber-200 bg-amber-50/50'
: 'border-violet-100 bg-white/90 hover:border-violet-200'"
>
<!-- Card header -->
<div class="flex items-center justify-between gap-2 px-4 pt-4">
<span
class="rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.08em]"
:class="row.statusClass"
>
{{ row.statusLabel }}
</span>
<span class="h-2 w-2 rounded-full" :class="row.dotClass" />
</div>
<!-- Card body -->
<div class="flex-1 px-4 pb-2 pt-3">
<h3 class="text-base font-bold text-slate-900">{{ row.title }}</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ row.description }}</p>
<!-- Date display or edit -->
<div class="mt-3">
<template v-if="row.isEditing">
<div class="space-y-2">
<template v-if="row.key === 'show'">
<input v-model="draft.start" type="date" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
<input v-model="draft.showStartsAt" type="time" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
</template>
<template v-else>
<input v-model="draft.start" type="date" placeholder="Start" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
<input v-model="draft.end" type="date" placeholder="Ende" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
</template>
</div>
</template>
<template v-else>
<p class="text-sm font-semibold text-slate-700">{{ row.dateRange }}</p>
<!-- Countdown badge for active/upcoming phases -->
<span
v-if="phaseCountdown(row)"
class="mt-2 inline-flex items-center gap-1.5 rounded-full border border-violet-100 bg-violet-50 px-2.5 py-1 text-[11px] font-semibold"
:class="row.active && phaseCountdown(row) === 'Überfällig' ? 'border-rose-200 bg-rose-50 text-rose-700' : 'text-violet-700'"
>
<Clock class="h-3 w-3" />
{{ phaseCountdown(row) }}
</span>
</template>
</div>
</div>
<!-- Card actions -->
<div class="flex flex-wrap gap-2 px-4 pb-4 pt-2">
<template v-if="row.isEditing">
<Button size="sm" class="flex-1" :disabled="saving" @click="saveEdit(row)">OK</Button>
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-slate-500 hover:bg-violet-50"
@click="cancelEdit"
>
<X class="h-4 w-4" />
</button>
</template>
<template v-else>
<!-- Activate phase button -->
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 rounded-2xl px-3 py-2 text-xs font-bold transition disabled:cursor-not-allowed disabled:opacity-60"
:class="row.active
? 'bg-violet-600 text-white'
: row.finalLocked
? 'border border-slate-200 bg-slate-50 text-slate-500'
: 'border border-violet-200 bg-white text-violet-700 hover:bg-violet-50'"
:disabled="saving || !selectedSeasonId || row.active || row.finalLocked"
@click="activatePhase(row.title)"
>
<CheckCircle2 v-if="row.active" class="h-3.5 w-3.5" />
<LockKeyhole v-else-if="row.finalLocked" class="h-3.5 w-3.5" />
<WandSparkles v-else class="h-3.5 w-3.5" />
{{ row.active ? 'Aktiv' : row.finalLocked ? 'Beenden nutzen' : 'Aktivieren' }}
</button>
<!-- Edit dates button -->
<button
v-if="row.editable"
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-violet-600 transition hover:bg-violet-50 disabled:opacity-50"
:disabled="saving || !selectedSeasonId"
title="Datum bearbeiten"
@click="startEdit(row)"
>
<Pencil class="h-3.5 w-3.5" />
</button>
<span v-else class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-100 bg-slate-50">
<LockKeyhole class="h-3.5 w-3.5 text-slate-400" />
</span>
</template>
</div>
</article>
</div>
</div>
<div class="border-t border-violet-100 bg-violet-50/30 px-6 py-3 text-xs leading-5 text-slate-500">
Timeline-Felder steuern Public-API, Vorschau und Teilnahme-Gates. Änderungen werden erst mit Speichern" gespeichert.
</div>
</Card>
<!-- Empty state -->
<Card v-else class="px-8 py-16 text-center">
<CalendarCog class="mx-auto h-8 w-8 text-violet-300" />
<p class="mt-4 text-sm text-slate-500">Noch keine Award-Jahre geladen.</p>
<Button class="mx-auto mt-5 gap-2" @click="openCreateModal">
<PlusCircle class="h-4 w-4" />
Erstes Jahr anlegen
</Button>
</Card>
</div>
<!-- Modals (unverändert) -->
<AdminSeasonCreateModal
:open="createModalOpen"
:create-form="createForm"
@@ -8,6 +8,7 @@ import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import AdminSettingsDatabaseCard from '../../components/admin/AdminSettingsDatabaseCard.vue'
import AdminSettingsOverviewBoard from '../../components/admin/AdminSettingsOverviewBoard.vue'
import { watchAdminErrorToast, watchAdminToast } from '../../composables/useAdminToast'
import { useAuthStore } from '../../stores/auth'
import { useAdminOperationalSettings } from '../../components/admin/useAdminOperationalSettings'
import { useAdminSettingsOverview } from '../../components/admin/useAdminSettingsOverview'
@@ -50,6 +51,9 @@ const {
saveOperationalSettings,
} = useAdminOperationalSettings()
watchAdminToast(operationalSuccess, operationalError)
watchAdminErrorToast(healthError)
function confirmDiscardOperationalChanges() {
if (!hasUnsavedOperationalChanges.value) {
return true
+172 -97
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { KeyRound, LockKeyhole, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { Check, KeyRound, LockKeyhole, Minus, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { computed, reactive, ref } from 'vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
import PasswordField from '../../components/ui/PasswordField.vue'
import { useAuthStore } from '../../stores/auth'
import type { AdminTeamMember } from '../../types/awards'
@@ -18,6 +20,7 @@ const canChangeOwnPassword = computed(() => authStore.isOwnerOrCreator && authSt
const passwordModalOpen = ref(false)
const createMemberModalOpen = ref(false)
const editMemberModalOpen = ref(false)
const rolePermissionsModalOpen = ref(false)
const profileError = ref('')
const profileSuccess = ref('')
const ownPasswordForm = reactive({
@@ -29,6 +32,8 @@ const ownPasswordForm = reactive({
const {
loading,
saving,
refreshingPresence,
lastPresenceRefreshAt,
errorMessage,
successMessage,
generatedPassword,
@@ -39,10 +44,10 @@ const {
permissions,
memberForm,
selectedMember,
activeMembers,
pendingPasswordChanges,
assignableRoleOptions,
hasRoleChanges,
loadTeam,
startCreateMember,
startEditMember,
saveMember,
@@ -55,12 +60,16 @@ const {
saveRolePermissions,
} = useAdminTeamManager()
watchAdminToast(successMessage, errorMessage)
watchAdminToast(profileSuccess, profileError)
const memberPendingDeletion = computed(() =>
members.value.find((member) => member.id === confirmDeleteMemberId.value) ?? null,
)
const onlineMembers = computed(() => members.value.filter((member) => member.isOnline).length)
const summaryItems = computed(() => [
{ label: 'Mitglieder', value: members.value.length, note: `${activeMembers.value} aktiv` },
{ label: 'Mitglieder', value: members.value.length, note: `${onlineMembers.value} online` },
{ label: 'Rollen', value: roles.value.length, note: 'Owner, Creator und Team' },
{ label: 'PW-Wechsel', value: pendingPasswordChanges.value, note: 'temporäre Passwörter offen' },
])
@@ -77,11 +86,30 @@ function formatDate(value: string | null) {
}).format(new Date(value))
}
function memberStatus(member: AdminTeamMember) {
function formatRefreshTime(value: Date | null) {
if (!value) return 'noch nicht aktualisiert'
return new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
}).format(value)
}
function memberAccountStatus(member: AdminTeamMember) {
if (!member.isActive) return 'Inaktiv'
return member.mustChangePassword ? 'PW-Wechsel offen' : 'Aktiv'
}
function memberOnlineStatus(member: AdminTeamMember) {
return member.isOnline ? 'Online' : 'Offline'
}
function rolePermissionLocked(roleKey: string) {
return roleKey === 'owner' || roleKey === 'creator'
}
function rolePermissionCount(roleKey: string) {
return permissions.value.filter((permission) => roleHasPermission(roleKey, permission.key)).length
}
function isOwnTeamAccount(member: AdminTeamMember) {
const teamLogin = authStore.session?.teamLogin?.toLowerCase()
const boundTwitchUserId = authStore.session?.boundTwitchUserId?.toLowerCase()
@@ -100,10 +128,6 @@ function canDeleteMember(member: AdminTeamMember) {
return true
}
function handlePermissionChange(roleKey: string, permissionKey: string, event: Event) {
setRolePermission(roleKey, permissionKey, event.target instanceof HTMLInputElement && event.target.checked)
}
function openCreateMemberModal() {
startCreateMember()
createMemberModalOpen.value = true
@@ -218,22 +242,8 @@ async function changeOwnPassword() {
Passwort ändern
</Button>
</div>
<div v-if="profileError" class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ profileError }}
</div>
<div v-if="profileSuccess" class="mt-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
{{ profileSuccess }}
</div>
</Card>
<div v-if="errorMessage" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
{{ successMessage }}
</div>
<Card v-if="generatedPassword" class="border-amber-200 bg-amber-50/80 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
@@ -251,11 +261,37 @@ async function changeOwnPassword() {
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Mitglieder</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Logins und Status</h2>
<p class="mt-1 text-xs font-semibold text-slate-500">
Online = aktive Admin-Session in den letzten 5 Minuten. Auto-Refresh alle 30 Sekunden.
<span class="text-slate-400">Zuletzt geprüft: {{ formatRefreshTime(lastPresenceRefreshAt) }}</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
size="sm"
type="button"
variant="ghost"
:disabled="loading || refreshingPresence"
@click="loadTeam({ silent: true })"
>
<RefreshCw class="mr-2 h-4 w-4" :class="refreshingPresence ? 'animate-spin' : ''" />
Aktualisieren
</Button>
<Button
size="sm"
type="button"
variant="ghost"
class="border border-violet-200 bg-violet-50 text-violet-700 shadow-sm shadow-violet-200/40 hover:-translate-y-0.5 hover:border-violet-300 hover:bg-violet-100 hover:text-violet-800 hover:shadow-lg hover:shadow-violet-200/60"
@click="rolePermissionsModalOpen = true"
>
<ShieldCheck class="mr-2 h-4 w-4" />
Rollenrechte
</Button>
<Button size="sm" type="button" @click="openCreateMemberModal">
<UserPlus class="mr-2 h-4 w-4" />
Neuer Login
</Button>
</div>
<Button size="sm" type="button" @click="openCreateMemberModal">
<UserPlus class="mr-2 h-4 w-4" />
Neuer Login
</Button>
</div>
<div v-if="loading" class="mt-5 rounded-xl border border-dashed border-violet-200 p-4 text-sm text-slate-500">
@@ -268,8 +304,8 @@ async function changeOwnPassword() {
<tr>
<th class="px-5 py-3">Login</th>
<th class="px-5 py-3">Rolle</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Letzter Login</th>
<th class="px-5 py-3">Online</th>
<th class="px-5 py-3">Zuletzt online</th>
<th class="px-5 py-3 text-right">Aktion</th>
</tr>
</thead>
@@ -288,19 +324,26 @@ async function changeOwnPassword() {
</td>
<td class="px-5 py-4 font-semibold text-slate-600">{{ roleLabel(member.role) }}</td>
<td class="px-5 py-4">
<span class="rounded-full border px-3 py-1 text-xs font-bold" :class="member.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
{{ memberStatus(member) }}
</span>
<div class="flex flex-col items-start gap-1.5">
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold" :class="member.isOnline ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
<span class="h-2 w-2 rounded-full" :class="member.isOnline ? 'bg-emerald-500' : 'bg-slate-300'" />
{{ memberOnlineStatus(member) }}
</span>
<span v-if="memberAccountStatus(member) !== 'Aktiv'" class="text-xs font-semibold text-amber-600">
{{ memberAccountStatus(member) }}
</span>
</div>
</td>
<td class="px-5 py-4 text-sm font-semibold text-slate-500">{{ formatDate(member.lastLoginAt) }}</td>
<td class="px-5 py-4 text-sm font-semibold text-slate-500">{{ formatDate(member.lastOnlineAt) }}</td>
<td class="px-5 py-4 text-right">
<div class="flex flex-wrap items-center justify-end gap-5">
<button
type="button"
class="text-sm font-bold text-slate-700 transition hover:text-violet-700 disabled:cursor-not-allowed disabled:text-slate-300"
class="inline-flex h-9 items-center gap-2 rounded-xl border border-violet-200 bg-white px-4 text-sm font-bold text-violet-700 shadow-sm shadow-violet-100/60 transition hover:-translate-y-0.5 hover:border-violet-300 hover:bg-violet-50 hover:text-violet-800 hover:shadow-lg hover:shadow-violet-200/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-300 disabled:shadow-none disabled:hover:translate-y-0"
:disabled="saving"
@click="openEditMemberModal(member)"
>
<Pencil class="h-4 w-4" />
Bearbeiten
</button>
<Button
@@ -315,7 +358,7 @@ async function changeOwnPassword() {
</Button>
<button
type="button"
class="inline-flex items-center gap-2 text-sm font-bold text-slate-700 transition hover:text-rose-600 disabled:cursor-not-allowed disabled:text-slate-300"
class="inline-flex h-9 items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 px-4 text-sm font-bold text-rose-700 shadow-sm shadow-rose-100/60 transition-all duration-200 hover:-translate-y-0.5 hover:border-rose-300 hover:bg-rose-100 hover:text-rose-800 hover:shadow-lg hover:shadow-rose-200/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-300 disabled:shadow-none disabled:hover:translate-y-0"
:disabled="saving || !canDeleteMember(member)"
@click="openDeleteMemberModal(member)"
>
@@ -330,54 +373,86 @@ async function changeOwnPassword() {
</div>
</Card>
<Card class="p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
<ShieldCheck class="h-4 w-4" />
</span>
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Berechtigungen einstellen</h2>
<Teleport to="body">
<div
v-if="rolePermissionsModalOpen"
class="fixed inset-0 z-[305] flex items-center justify-center bg-slate-950/50 p-3 backdrop-blur-sm md:p-6"
@click.self="rolePermissionsModalOpen = false"
>
<Card class="flex max-h-[88vh] w-full max-w-6xl flex-col overflow-hidden rounded-[30px] bg-white p-0 shadow-[0_34px_100px_rgba(39,28,72,0.28)]">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-violet-100 bg-white px-5 py-4 md:px-6">
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-50 text-violet-600 ring-1 ring-violet-100">
<ShieldCheck class="h-5 w-5" />
</span>
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-950">Berechtigungen einstellen</h2>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="ghost" @click="rolePermissionsModalOpen = false">Schließen</Button>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
<Save class="mr-2 h-4 w-4" />
Speichern
</Button>
</div>
</div>
</div>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
<Save class="mr-2 h-4 w-4" />
Speichern
</Button>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-violet-100">
<table class="min-w-[920px] divide-y divide-violet-100 text-sm">
<thead class="bg-violet-50/70 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">
<tr>
<th class="sticky left-0 z-10 bg-violet-50/95 px-3 py-2">Menüpunkt</th>
<th v-for="role in roles" :key="role.key" class="px-3 py-2 text-center">
{{ role.label }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-violet-100 bg-white/70">
<tr v-for="permission in permissions" :key="permission.key">
<td class="sticky left-0 z-10 max-w-[260px] bg-white px-3 py-3">
<span class="block font-bold text-slate-900">{{ permission.label }}</span>
<span class="block text-xs leading-5 text-slate-500">{{ permission.description }}</span>
</td>
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-3 text-center">
<input
class="h-4 w-4 accent-violet-600 disabled:opacity-60"
type="checkbox"
:checked="roleHasPermission(role.key, permission.key)"
:disabled="role.key === 'owner' || role.key === 'creator'"
:aria-label="`${role.label}: ${permission.label}`"
@change="handlePermissionChange(role.key, permission.key, $event)"
>
</td>
</tr>
</tbody>
</table>
<div class="min-h-0 flex-1 overflow-auto bg-slate-50/70 p-3 md:p-5">
<table class="w-full min-w-[1040px] overflow-hidden rounded-2xl border border-violet-100 bg-white text-sm shadow-sm">
<thead class="sticky top-0 z-20 bg-violet-50 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500 shadow-[0_1px_0_rgba(221,214,254,0.85)]">
<tr>
<th class="sticky left-0 z-30 w-[340px] bg-violet-50 px-5 py-4">Bereich</th>
<th v-for="role in roles" :key="role.key" class="min-w-[112px] px-3 py-4 text-center">
<span class="block text-[11px] leading-4 text-violet-600">{{ role.label }}</span>
<span class="mt-1 block text-[10px] font-extrabold tracking-normal text-slate-400">
{{ rolePermissionCount(role.key) }}/{{ permissions.length }}
</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-violet-100 bg-white">
<tr v-for="permission in permissions" :key="permission.key" class="transition hover:bg-violet-50/35">
<td class="sticky left-0 z-10 w-[340px] bg-white px-5 py-4 shadow-[1px_0_0_rgba(237,233,254,0.95)]">
<span class="block text-base font-bold leading-5 text-slate-950">{{ permission.label }}</span>
<span class="mt-1 block max-w-[300px] text-xs font-semibold leading-5 text-slate-500">{{ permission.description }}</span>
</td>
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-4 text-center">
<button
type="button"
class="relative mx-auto grid h-9 w-9 place-items-center rounded-xl ring-1 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
:class="[
roleHasPermission(role.key, permission.key)
? rolePermissionLocked(role.key)
? 'bg-slate-100 text-slate-400 ring-slate-200'
: 'bg-violet-600 text-white shadow-lg shadow-violet-500/20 ring-violet-500 hover:bg-violet-500'
: rolePermissionLocked(role.key)
? 'bg-slate-50 text-slate-300 ring-slate-200'
: 'bg-white text-slate-300 ring-slate-300 hover:text-violet-500 hover:ring-violet-300',
]"
:disabled="rolePermissionLocked(role.key)"
:aria-label="`${role.label}: ${permission.label}`"
:aria-pressed="roleHasPermission(role.key, permission.key)"
@click="setRolePermission(role.key, permission.key, !roleHasPermission(role.key, permission.key))"
>
<Check v-if="roleHasPermission(role.key, permission.key)" class="h-5 w-5" :stroke-width="3" />
<Minus v-else class="h-4 w-4" :stroke-width="3" />
<span
v-if="rolePermissionLocked(role.key) && roleHasPermission(role.key, permission.key)"
class="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-white text-slate-400 ring-1 ring-slate-200"
>
<LockKeyhole class="h-2.5 w-2.5" />
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</div>
</Card>
</Teleport>
<Teleport to="body">
<div
@@ -429,25 +504,28 @@ async function changeOwnPassword() {
<label class="grid gap-2 text-base font-bold text-slate-600">
Rolle
<select
<NativeSelect
v-model="memberForm.role"
class="h-12 rounded-2xl border border-violet-100 bg-white px-4 text-base font-bold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
:options="assignableRoleOptions"
/>
</label>
<label
v-if="editMemberModalOpen"
class="flex h-14 cursor-pointer items-center justify-between gap-3 rounded-2xl border px-4 text-base font-bold shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
:class="memberForm.isActive ? 'border-violet-200 bg-violet-50 text-violet-800 shadow-violet-100/60 hover:border-violet-300 hover:bg-violet-100' : 'border-slate-200 bg-white text-slate-600 shadow-slate-100 hover:border-slate-300 hover:bg-slate-50'"
>
<span>Account aktiv</span>
<span
class="grid h-9 w-9 place-items-center rounded-xl border transition"
:class="memberForm.isActive ? 'border-violet-500 bg-violet-600 text-white shadow-lg shadow-violet-500/20' : 'border-slate-300 bg-white text-slate-300'"
>
<option v-for="role in assignableRoleOptions" :key="role.value" :value="role.value">
{{ role.label }}
</option>
</select>
<Check v-if="memberForm.isActive" class="h-5 w-5" :stroke-width="3" />
<Minus v-else class="h-4 w-4" :stroke-width="3" />
</span>
<input v-model="memberForm.isActive" type="checkbox" class="sr-only">
</label>
<label v-if="editMemberModalOpen" class="flex h-14 items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 text-base font-bold text-slate-700">
Account aktiv
<input v-model="memberForm.isActive" type="checkbox" class="h-5 w-5 accent-violet-600">
</label>
<div v-if="errorMessage" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ errorMessage }}
</div>
<Button type="submit" :disabled="saving" class="h-14 text-base">
<Save class="mr-2 h-5 w-5" />
{{ saving ? 'Speichert...' : createMemberModalOpen ? 'Login erstellen' : 'Speichern' }}
@@ -520,9 +598,6 @@ async function changeOwnPassword() {
Passwort wiederholen
<PasswordField v-model="ownPasswordForm.confirmPassword" autocomplete="new-password" required minlength="10" input-class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-violet-300 focus:ring-2 focus:ring-violet-100" />
</label>
<div v-if="profileError" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ profileError }}
</div>
<Button type="submit" :disabled="authStore.loading">
<Save class="mr-2 h-4 w-4" />
{{ authStore.loading ? 'Speichert...' : 'Passwort speichern' }}
@@ -6,6 +6,7 @@ import AdminAuditFocusPanel from '../../components/admin/AdminAuditFocusPanel.vu
import AdminAuditLogList from '../../components/admin/AdminAuditLogList.vue'
import AdminAuditOverviewBar from '../../components/admin/AdminAuditOverviewBar.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import { watchAdminErrorToast } from '../../composables/useAdminToast'
import { useAdminAuditManager } from '../../components/admin/useAdminAuditManager'
const {
@@ -42,6 +43,8 @@ const {
openAuditEntry,
closeAuditEntry,
} = useAdminAuditManager()
watchAdminErrorToast(auditError)
</script>
<template>
@@ -72,8 +75,6 @@ const {
@apply-preset="applyPreset"
/>
<p v-if="auditError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ auditError }}</p>
<section class="grid gap-6 2xl:grid-cols-[360px_minmax(0,1fr)]">
<AdminAuditFocusPanel
v-model:selected-admin="selectedAdmin"
+13 -11
View File
@@ -4,8 +4,10 @@ import { Award, CheckCircle2, Filter, Trash2, Trophy } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminWinnersManager } from '../../components/admin/useAdminWinnersManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
const {
adminError,
@@ -22,6 +24,8 @@ const {
clearWinner,
saveWinner,
} = useAdminWinnersManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -98,9 +102,6 @@ const {
</div>
</div>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div v-if="visibleResultRows.length" class="divide-y divide-violet-50">
<article
v-for="row in visibleResultRows"
@@ -130,16 +131,17 @@ const {
</p>
</div>
<select
<NativeSelect
v-model="winnerSelections[row.category.id]"
:disabled="row.isEmpty"
class="h-12 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
>
<option value="">Bitte Gewinner waehlen</option>
<option v-for="candidate in row.candidates" :key="candidate.id" :value="`${candidate.id}`">
{{ candidate.displayName }} · {{ candidate.channelSlug }} · {{ candidate.platform }}
</option>
</select>
:options="[
{ label: 'Bitte Gewinner waehlen', value: '' },
...row.candidates.map((candidate) => ({
label: `${candidate.displayName} · ${candidate.channelSlug} · ${candidate.platform}`,
value: `${candidate.id}`,
})),
]"
/>
<div class="flex flex-wrap justify-end gap-2">
<Button :disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id]" @click="saveWinner(row.category.id)">