Refine admin workflows and team access
This commit is contained in:
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)">
|
||||
|
||||
Reference in New Issue
Block a user