Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="entry" class="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-slate-950/35 backdrop-blur-sm"
|
||||
aria-label="Audit-Details schließen"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
|
||||
<aside class="absolute right-0 top-0 flex h-full w-full max-w-2xl flex-col overflow-hidden bg-white shadow-2xl">
|
||||
<header class="border-b border-slate-100 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-sky-500">Audit Detail</p>
|
||||
<h2 class="mt-2 text-2xl font-bold leading-8 text-slate-950">{{ entry.actionLabel }}</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ entry.summary }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"
|
||||
aria-label="Schließen"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-5">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Zeitpunkt</p>
|
||||
<p class="mt-1 font-semibold text-slate-900">{{ entry.createdLabel }}</p>
|
||||
<p class="text-xs text-slate-500">{{ entry.ageLabel }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Admin</p>
|
||||
<p class="mt-1 break-words font-semibold text-slate-900">{{ entry.adminTwitchUserId }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Objekt</p>
|
||||
<p class="mt-1 break-words font-semibold text-slate-900">{{ entry.entityLabel }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Rohaktion</p>
|
||||
<code class="mt-1 inline-block rounded-md bg-white px-2 py-1 text-xs font-semibold text-slate-700">{{ entry.actionType }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
v-if="entry.relatedLink"
|
||||
:to="entry.relatedLink.to"
|
||||
class="mt-4 inline-flex h-10 items-center gap-2 rounded-2xl border border-sky-200 bg-sky-50 px-4 text-sm font-semibold text-sky-700 transition hover:bg-sky-100"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
{{ entry.relatedLink.label }}
|
||||
</RouterLink>
|
||||
|
||||
<section class="mt-6 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheck class="h-4 w-4 text-slate-500" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Request-Kontext</h3>
|
||||
</div>
|
||||
<dl class="grid gap-3">
|
||||
<div v-for="item in entry.requestContextItems" :key="item.key" class="rounded-2xl border border-slate-100 px-4 py-3">
|
||||
<dt class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">{{ item.key }}</dt>
|
||||
<dd class="mt-1 break-words text-sm font-semibold text-slate-800">{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="entry.changeItems.length > 0" class="mt-6 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<GitCompareArrows class="h-4 w-4 text-amber-600" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Änderungsdiff</h3>
|
||||
</div>
|
||||
<dl class="grid gap-3">
|
||||
<div v-for="change in entry.changeItems" :key="`${change.field}-${change.to}`" class="rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3">
|
||||
<dt class="flex flex-wrap items-center gap-2 text-sm font-bold text-slate-800">
|
||||
{{ change.label }}
|
||||
<span v-if="change.sensitive" class="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-amber-700">
|
||||
sensibel
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<span class="min-w-0 rounded-xl bg-white/80 px-3 py-2 text-sm text-slate-600">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Vorher</span>
|
||||
<span class="break-words">{{ change.from || 'leer' }}</span>
|
||||
</span>
|
||||
<span class="min-w-0 rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-emerald-600">Nachher</span>
|
||||
<span class="break-words">{{ change.to || 'leer' }}</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ListTree class="h-4 w-4 text-slate-500" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Metadaten</h3>
|
||||
</div>
|
||||
<dl v-if="entry.metadataItems.length > 0" class="grid gap-3 sm:grid-cols-2">
|
||||
<div v-for="item in entry.metadataItems" :key="item.key" class="min-w-0 rounded-2xl border border-slate-100 px-4 py-3">
|
||||
<dt class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">{{ item.key }}</dt>
|
||||
<dd class="mt-1 break-words text-sm text-slate-700">{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-else class="rounded-2xl border border-dashed border-slate-200 px-4 py-5 text-sm text-slate-500">
|
||||
Keine Metadaten erfasst.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Braces class="h-4 w-4 text-slate-500" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Rohdaten</h3>
|
||||
</div>
|
||||
<pre class="max-h-72 overflow-auto rounded-2xl border border-slate-200 bg-slate-950 p-4 text-xs leading-5 text-slate-100">{{ entry.rawMetadataJson }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { Braces, ExternalLink, GitCompareArrows, ListTree, ShieldCheck, X } from '@lucide/vue'
|
||||
|
||||
import type { AuditLogRow } from './useAdminAuditManager'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: AuditLogRow | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.entry) emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.entry,
|
||||
(entry) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = entry ? 'hidden' : ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', closeOnEscape))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', closeOnEscape)
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<Card class="p-5">
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-500">Überblick</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Schwerpunkte</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div v-for="card in focusCards" :key="card.label" class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ card.label }}</p>
|
||||
<strong class="mt-1 block truncate text-base text-slate-950">{{ card.value }}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">{{ card.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Admins</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-sky-600 hover:text-sky-800"
|
||||
@click="$emit('update:selectedAdmin', 'all')"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-48 space-y-2 overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="item in adminCounts"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-3 py-2 text-left text-sm transition"
|
||||
:class="selectedAdmin === item.key ? 'border-sky-200 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'"
|
||||
@click="$emit('update:selectedAdmin', item.key)"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold">{{ item.label }}</span>
|
||||
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-bold text-sky-700">{{ item.count }}</span>
|
||||
</button>
|
||||
<p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-slate-200 px-3 py-5 text-center text-sm text-slate-500">
|
||||
Noch keine Admin-Aktivität.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Aktionen</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-sky-600 hover:text-sky-800"
|
||||
@click="$emit('update:selectedAction', 'all')"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-56 space-y-2 overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="item in actionCounts"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-3 py-2 text-left text-sm transition"
|
||||
:class="selectedAction === item.key ? 'border-sky-200 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'"
|
||||
@click="$emit('update:selectedAction', item.key)"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold">{{ item.label }}</span>
|
||||
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-bold text-sky-700">{{ item.count }}</span>
|
||||
</button>
|
||||
<p v-if="actionCounts.length === 0" class="rounded-2xl border border-dashed border-slate-200 px-3 py-5 text-center text-sm text-slate-500">
|
||||
Noch keine Aktionstypen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Objekte</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-sky-600 hover:text-sky-800"
|
||||
@click="$emit('update:selectedEntity', 'all')"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-48 space-y-2 overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="item in entityCounts"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-3 py-2 text-left text-sm transition"
|
||||
:class="selectedEntity === item.key ? 'border-sky-200 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'"
|
||||
@click="$emit('update:selectedEntity', item.key)"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold">{{ item.label }}</span>
|
||||
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-bold text-sky-700">{{ item.count }}</span>
|
||||
</button>
|
||||
<p v-if="entityCounts.length === 0" class="rounded-2xl border border-dashed border-slate-200 px-3 py-5 text-center text-sm text-slate-500">
|
||||
Noch keine Objekttypen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditCountItem, AuditFocusCard } from './useAdminAuditManager'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
focusCards: AuditFocusCard[]
|
||||
adminCounts: AuditCountItem[]
|
||||
actionCounts: AuditCountItem[]
|
||||
entityCounts: AuditCountItem[]
|
||||
selectedAdmin: string
|
||||
selectedAction: string
|
||||
selectedEntity: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:selectedAdmin': [value: string]
|
||||
'update:selectedAction': [value: string]
|
||||
'update:selectedEntity': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-slate-100 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-500">Audit Log</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Letzte Aktionen</h2>
|
||||
</div>
|
||||
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-sky-100 text-sky-700">
|
||||
<FileClock class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[760px] divide-y divide-slate-100 overflow-y-auto" :aria-busy="loading">
|
||||
<article v-for="entry in entries" :key="entry.id" class="grid gap-4 px-5 py-4 sm:grid-cols-[16px_minmax(0,1fr)]">
|
||||
<span class="mt-2 h-3 w-3 rounded-full ring-4 ring-white" :class="entry.dotClass" />
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-semibold" :class="entry.actionToneClass">
|
||||
{{ entry.actionLabel }}
|
||||
</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{{ entry.actionGroup }}</span>
|
||||
</div>
|
||||
<h3 class="mt-3 text-base font-bold leading-6 text-slate-950">{{ entry.summary }}</h3>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-start gap-3">
|
||||
<time class="text-right text-sm text-slate-500" :datetime="entry.createdAt">
|
||||
{{ entry.createdLabel }}
|
||||
<span class="block text-xs text-slate-400">{{ entry.ageLabel }}</span>
|
||||
</time>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-slate-200 bg-white px-3 text-slate-700 shadow-none hover:bg-slate-50"
|
||||
@click="$emit('openDetail', entry)"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-2 text-sm text-slate-500 md:grid-cols-3">
|
||||
<p>
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Admin</span>
|
||||
<span class="font-semibold text-slate-700">{{ entry.adminTwitchUserId }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Objekt</span>
|
||||
<span class="font-semibold text-slate-700">{{ entry.entityLabel }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Rohaktion</span>
|
||||
<code class="rounded-md bg-slate-100 px-1.5 py-0.5 text-xs font-semibold text-slate-700">{{ entry.actionType }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="entry.changeItems.length > 0" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3">
|
||||
<p class="text-sm font-semibold text-amber-800">Änderungsdiff</p>
|
||||
<dl class="mt-3 grid gap-2">
|
||||
<div
|
||||
v-for="change in entry.changeItems"
|
||||
:key="`${change.field}-${change.to}`"
|
||||
class="grid gap-2 rounded-xl border border-white/80 bg-white/80 px-3 py-2 text-sm lg:grid-cols-[150px_minmax(0,1fr)]"
|
||||
>
|
||||
<dt class="font-bold text-slate-700">
|
||||
{{ change.label }}
|
||||
<span v-if="change.sensitive" class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-amber-700">
|
||||
sensibel
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="grid gap-2 sm:grid-cols-2">
|
||||
<span class="min-w-0 rounded-lg bg-slate-50 px-2 py-1 text-slate-600">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Vorher</span>
|
||||
<span class="break-words">{{ change.from || 'leer' }}</span>
|
||||
</span>
|
||||
<span class="min-w-0 rounded-lg bg-emerald-50 px-2 py-1 text-emerald-800">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-emerald-600">Nachher</span>
|
||||
<span class="break-words">{{ change.to || 'leer' }}</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<details v-if="entry.metadataItems.length > 0" class="mt-3 rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-sky-700">Metadaten anzeigen</summary>
|
||||
<dl class="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div v-for="item in entry.metadataItems" :key="item.key" class="min-w-0">
|
||||
<dt class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">{{ item.key }}</dt>
|
||||
<dd class="mt-0.5 break-words text-slate-700">{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="entries.length === 0" class="px-5 py-14 text-center text-sm text-slate-500">
|
||||
{{ emptyText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4">
|
||||
<p class="text-sm font-semibold text-slate-500">{{ pageSummaryLabel }}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-slate-700 shadow-none hover:bg-slate-50"
|
||||
:disabled="!hasMore || loadingMore || loading"
|
||||
@click="$emit('loadMore')"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4" :class="{ 'animate-bounce': loadingMore }" />
|
||||
{{ hasMore ? (loadingMore ? 'Lädt ...' : 'Mehr laden') : 'Alles geladen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, Eye, FileClock } from '@lucide/vue'
|
||||
|
||||
import type { AuditLogRow } from './useAdminAuditManager'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
entries: AuditLogRow[]
|
||||
loading: boolean
|
||||
loadingMore: boolean
|
||||
hasMore: boolean
|
||||
pageSummaryLabel: string
|
||||
emptyText: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
openDetail: [entry: AuditLogRow]
|
||||
loadMore: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<Card class="p-5">
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(260px,1.5fr)_180px_150px_150px]">
|
||||
<label class="relative block">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-sky-500" />
|
||||
<input
|
||||
:value="query"
|
||||
class="h-12 w-full rounded-2xl border border-slate-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
placeholder="Admin, Aktion, Objekt, Summary oder Metadaten suchen"
|
||||
type="search"
|
||||
@input="$emit('update:query', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<NativeSelect
|
||||
:model-value="entityFilter"
|
||||
:options="entityOptions"
|
||||
@update:model-value="$emit('update:entityFilter', String($event ?? 'all'))"
|
||||
/>
|
||||
|
||||
<label class="relative block">
|
||||
<CalendarDays class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
:value="fromDate"
|
||||
class="h-12 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
type="date"
|
||||
@input="$emit('update:fromDate', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="relative block">
|
||||
<CalendarDays class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
:value="toDate"
|
||||
class="h-12 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
type="date"
|
||||
@input="$emit('update:toDate', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-2 xl:justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-sky-200 bg-sky-50 px-4 text-sky-700 shadow-none hover:bg-sky-100"
|
||||
:disabled="loading"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
||||
{{ loading ? 'Lädt ...' : 'Aktualisieren' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-slate-700 shadow-none hover:bg-slate-50"
|
||||
:disabled="!hasRows"
|
||||
@click="$emit('export')"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
v-if="activeFilterCount > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-slate-600 shadow-none hover:bg-slate-50"
|
||||
@click="$emit('clear')"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 2xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in filterPresets"
|
||||
:key="preset.key"
|
||||
type="button"
|
||||
class="min-h-12 rounded-2xl border px-4 py-2 text-left text-sm transition"
|
||||
:class="appliedPresetKey === preset.key ? 'border-sky-300 bg-sky-50 text-sky-800' : 'border-slate-200 bg-white text-slate-700 hover:border-sky-200 hover:bg-sky-50'"
|
||||
@click="$emit('applyPreset', preset.key)"
|
||||
>
|
||||
<span class="block font-bold">{{ preset.label }}</span>
|
||||
<span class="block max-w-64 truncate text-xs opacity-75">{{ preset.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div v-for="stat in stats" :key="stat.label" class="rounded-2xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="mt-1 block text-lg leading-6 text-slate-950">{{ stat.value }}</strong>
|
||||
<p class="truncate text-[11px] text-slate-400">{{ stat.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs font-semibold text-slate-400">
|
||||
<span>Geladen: {{ lastLoadedLabel }} · Quelle: /api/admin/audit-entries</span>
|
||||
<span v-if="exportMessage" class="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-emerald-700">{{ exportMessage }}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CalendarDays, Download, RefreshCw, Search, X } from '@lucide/vue'
|
||||
|
||||
import type { AuditEntityOption, AuditFilterPreset, AuditStat } from './useAdminAuditManager'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
|
||||
defineProps<{
|
||||
query: string
|
||||
entityFilter: string
|
||||
entityOptions: AuditEntityOption[]
|
||||
fromDate: string
|
||||
toDate: string
|
||||
stats: AuditStat[]
|
||||
filterPresets: AuditFilterPreset[]
|
||||
appliedPresetKey: string
|
||||
exportMessage: string
|
||||
lastLoadedLabel: string
|
||||
loading: boolean
|
||||
hasRows: boolean
|
||||
activeFilterCount: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:query': [value: string]
|
||||
'update:entityFilter': [value: string]
|
||||
'update:fromDate': [value: string]
|
||||
'update:toDate': [value: string]
|
||||
applyPreset: [value: string]
|
||||
refresh: []
|
||||
export: []
|
||||
clear: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<Card class="flex h-[520px] flex-col overflow-hidden xl:h-[684px]">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] px-5 py-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Award-Jahre</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ seasons.length }} Jahre im Setup</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="gap-2 border border-violet-100 text-violet-700 hover:bg-violet-50" @click="openCreateModal">
|
||||
<PlusCircle class="h-4 w-4" />
|
||||
Neu
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<template v-if="selectedSeason">
|
||||
<div class="rounded-[24px] border border-violet-100 bg-white p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Ausgewählt</p>
|
||||
<strong class="mt-2 block text-5xl leading-none text-slate-950">{{ selectedSeason.year }}</strong>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-bold" :class="selectedSeason.isCurrent ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
|
||||
{{ selectedSeason.isCurrent ? 'Public' : 'Intern' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-5 line-clamp-2 text-lg font-bold leading-6 text-slate-900">{{ selectedSeason.name }}</p>
|
||||
<div class="mt-5 grid gap-3 border-t border-violet-100 pt-4">
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl bg-violet-50/70 px-4 py-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Phase</span>
|
||||
<strong class="truncate text-sm text-violet-800">{{ selectedSeason.currentPhase }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl bg-violet-50/70 px-4 py-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Kategorien</span>
|
||||
<strong class="text-sm text-slate-900">{{ selectedSeason.categoryCount }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl bg-violet-50/70 px-4 py-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Sichtbarkeit</span>
|
||||
<strong class="text-sm text-slate-900">{{ selectedSeason.isCurrent ? 'Public aktiv' : 'Intern' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-end text-xs text-slate-500">
|
||||
<span v-if="switchingSeasonId === selectedSeason.id" class="inline-flex items-center gap-1 font-bold text-violet-600">
|
||||
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
|
||||
lädt
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr wechseln</span>
|
||||
<NativeSelect v-model="selectedSeasonSelectValue" :options="seasonOptions" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div v-else class="px-5 py-8 text-center">
|
||||
<CalendarPlus class="mx-auto h-7 w-7 text-violet-300" />
|
||||
<p class="mt-3 text-sm text-slate-500">Noch keine Award-Jahre aus der API geladen.</p>
|
||||
<Button class="mt-5 gap-2" size="sm" @click="openCreateModal">
|
||||
<PlusCircle class="h-4 w-4" />
|
||||
Erstes Jahr anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { CalendarPlus, LoaderCircle, PlusCircle } from '@lucide/vue'
|
||||
|
||||
import type { AdminSeasonListItem } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
seasons: AdminSeasonListItem[]
|
||||
selectedSeasonId: number | null
|
||||
openCreateModal: () => void
|
||||
loadSeasonDetail: (seasonId: number) => Promise<void>
|
||||
}>()
|
||||
|
||||
const switchingSeasonId = ref<number | null>(null)
|
||||
const selectedSeason = computed(() =>
|
||||
props.seasons.find((season) => season.id === props.selectedSeasonId) ?? props.seasons[0] ?? null,
|
||||
)
|
||||
const seasonOptions = computed(() =>
|
||||
props.seasons.map((season) => ({
|
||||
label: `${season.year} · ${season.name}`,
|
||||
value: season.id,
|
||||
})),
|
||||
)
|
||||
const selectedSeasonSelectValue = computed({
|
||||
get: () => props.selectedSeasonId,
|
||||
set: (value) => {
|
||||
if (typeof value === 'number') {
|
||||
void selectSeason(value)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function selectSeason(seasonId: number) {
|
||||
if (seasonId === props.selectedSeasonId || switchingSeasonId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
switchingSeasonId.value = seasonId
|
||||
try {
|
||||
await props.loadSeasonDetail(seasonId)
|
||||
} finally {
|
||||
switchingSeasonId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<Modal :open="!!candidate" title="Kandidat löschen?" @close="$emit('close')">
|
||||
<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">{{ candidate?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
|
||||
Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="$emit('confirm')">
|
||||
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
defineProps<{
|
||||
candidate: AdminCandidateItem | null
|
||||
deleting: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<Modal :open="open" :title="title" subtitle="Anzeigename und Handle sind Pflicht." @close="$emit('close')">
|
||||
<div class="space-y-4">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
|
||||
<NativeSelect :model-value="form.categoryId" :options="categoryOptions" @update:model-value="$emit('update:categoryId', Number($event) || 0)" />
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input :value="form.displayName" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Jayuhime" @input="$emit('update:displayName', ($event.target as HTMLInputElement).value)" />
|
||||
</label>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||
<input :value="form.channelSlug" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" @input="$emit('update:channelSlug', ($event.target as HTMLInputElement).value)" />
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||
<select :value="selectedPlatformValue" class="h-11 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" @change="$emit('platform-selection', $event)">
|
||||
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
<option value="custom">Eigene Plattform</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label v-if="selectedPlatformValue === 'custom'" class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
|
||||
<input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" />
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||
<Button :disabled="saving || !canSave" @click="$emit('save')">{{ saving ? 'Speichert …' : 'Speichern' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SocialIconOption } from '../../lib/socialIcons'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
form: {
|
||||
categoryId: number
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}
|
||||
categoryOptions: Array<{ label: string; value: number }>
|
||||
candidatePlatformOptions: SocialIconOption[]
|
||||
selectedPlatformValue: string
|
||||
canSave: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
save: []
|
||||
'update:categoryId': [value: number]
|
||||
'update:displayName': [value: string]
|
||||
'update:channelSlug': [value: string]
|
||||
'update:platform': [value: string]
|
||||
'platform-selection': [event: Event]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
|
||||
<label class="relative block flex-1">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
:value="search"
|
||||
type="text"
|
||||
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="Name, Handle oder Plattform suchen …"
|
||||
@input="$emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<NativeSelect
|
||||
:model-value="categoryFilter"
|
||||
:options="categoryFilterOptions"
|
||||
@update:model-value="$emit('update:categoryFilter', $event === null ? null : Number($event))"
|
||||
/>
|
||||
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="$emit('clear-filters')">
|
||||
<X class="h-4 w-4" /> Filter
|
||||
</Button>
|
||||
<Button class="gap-2" @click="$emit('open-create')">
|
||||
<UserPlus class="h-4 w-4" /> Kandidat anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search, UserPlus, X } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
|
||||
defineProps<{
|
||||
search: string
|
||||
categoryFilter: number | null
|
||||
categoryFilterOptions: Array<{ label: string; value: number | null }>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:search': [value: string]
|
||||
'update:categoryFilter': [value: number | null]
|
||||
'clear-filters': []
|
||||
'open-create': []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
|
||||
<span>Kandidat</span>
|
||||
<span>Kategorie</span>
|
||||
<span>Plattform</span>
|
||||
<span class="text-right">Aktionen</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div
|
||||
v-for="candidate in pagedCandidates"
|
||||
:key="candidate.id"
|
||||
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
|
||||
{{ candidate.displayName.charAt(0) }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
|
||||
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
|
||||
Mögliches Duplikat in dieser Kategorie
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="inline-block max-w-full truncate rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 lg:justify-end">
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
|
||||
title="Bearbeiten"
|
||||
@click="$emit('edit', candidate)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
|
||||
title="Löschen"
|
||||
@click="$emit('delete', candidate)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCount === 0" class="px-6 py-12 text-center">
|
||||
<p class="text-sm text-slate-500">
|
||||
{{ totalCount === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
|
||||
</p>
|
||||
<Button class="mt-4 gap-2" @click="$emit('open-create')"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCount > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
|
||||
<span><strong class="text-violet-800">{{ rangeStart }}–{{ rangeEnd }}</strong> von {{ filteredCount }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
:disabled="page <= 1"
|
||||
@click="$emit('update:page', page - 1)"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
:disabled="page >= totalPages"
|
||||
@click="$emit('update:page', page + 1)"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Pencil, Trash2, UserPlus } from '@lucide/vue'
|
||||
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
pagedCandidates: AdminCandidateItem[]
|
||||
totalCount: number
|
||||
filteredCount: number
|
||||
page: number
|
||||
totalPages: number
|
||||
rangeStart: number
|
||||
rangeEnd: number
|
||||
categoryLabelMap: Record<number, string>
|
||||
duplicateCandidateKeys: Map<string, number>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [candidate: AdminCandidateItem]
|
||||
delete: [candidate: AdminCandidateItem]
|
||||
'update:page': [page: number]
|
||||
'open-create': []
|
||||
}>()
|
||||
|
||||
function isDuplicate(candidate: AdminCandidateItem) {
|
||||
return (props.duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1
|
||||
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<Card id="content-basics" class="overflow-hidden p-0">
|
||||
<div class="border-b border-violet-100 bg-gradient-to-r from-violet-50 via-white to-amber-50/60 px-6 py-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Basis</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Host & Hero-Informationen</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Diese Werte erscheinen direkt im Hero-Hostblock und im Community-Bereich.</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-3">
|
||||
<Globe2 class="h-6 w-6 text-violet-500" />
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Basis speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 p-6 md:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Name</span>
|
||||
<input v-model="form.hostDisplayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Tagline</span>
|
||||
<input v-model="form.hostTagline" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2 md:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Newsletter URL</span>
|
||||
<input v-model="form.newsletterUrl" type="url" 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" />
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Globe2, Save } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('Basis & Host')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Card id="content-faq" class="p-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Support</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">FAQ verwalten</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Fragen und Antworten erscheinen im FAQ-Abschnitt der Landingpage.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:justify-end">
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addFaqItem">
|
||||
<Plus class="h-4 w-4" />
|
||||
FAQ hinzufügen
|
||||
</Button>
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'FAQ speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-3">
|
||||
<div v-for="(item, index) in form.faq" :key="`faq-${index}`" class="space-y-3 rounded-[22px] border border-violet-100 bg-white/80 p-4">
|
||||
<input v-model="item.question" type="text" class="h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Frage" />
|
||||
<textarea v-model="item.answer" rows="3" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Antwort" />
|
||||
<div class="flex justify-end">
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-rose-100 bg-rose-50 px-4 text-rose-600 shadow-none hover:bg-rose-100" @click="removeFaqItem(index)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Save, Trash2 } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
addFaqItem: () => void
|
||||
removeFaqItem: (index: number) => void
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('FAQ')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<Card id="content-links" 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">Footer & Kontakt</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Rechtliche Links und Kontaktwege</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Diese URLs werden im Footer und in den öffentlichen Kontaktflächen ausgespielt.</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-3">
|
||||
<Link2 class="h-6 w-6 text-violet-500" />
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Links speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 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">Datenschutz E-Mail</span>
|
||||
<input v-model="form.privacyEmail" type="email" 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kontakt URL</span>
|
||||
<input v-model="form.contactUrl" type="url" 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Impressum URL</span>
|
||||
<input v-model="form.imprintUrl" type="url" 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Sponsoren & Partner URL</span>
|
||||
<input v-model="form.sponsorsUrl" type="url" 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" />
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Link2, Save } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('Footer & Kontakt')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-[120] flex items-center justify-center bg-[#25123f]/70 px-4 py-8 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="max-h-[88vh] w-full max-w-4xl overflow-hidden rounded-[34px] border border-violet-100 bg-white shadow-[0_34px_100px_rgba(38,18,63,0.35)]">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-gradient-to-r from-[#f8f2ff] via-white to-[#fff1f8] px-7 py-6">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Rechtliches Preview</p>
|
||||
<h3 class="mt-2 text-xl font-bold text-slate-900">Datenschutzerklärung</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">Stand: {{ updatedLabel }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-violet-100 text-violet-600 transition hover:bg-violet-200"
|
||||
aria-label="Preview schließen"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-[calc(88vh-140px)] space-y-4 overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
|
||||
<p
|
||||
v-for="(block, index) in blocks"
|
||||
:key="`privacy-modal-block-${index}`"
|
||||
class="whitespace-pre-wrap rounded-[18px] border border-violet-50 bg-white/86 px-4 py-3 shadow-sm"
|
||||
>
|
||||
{{ block }}
|
||||
</p>
|
||||
<p v-if="!blocks.length" class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
|
||||
Noch kein Datenschutztext eingetragen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { X } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
blocks: string[]
|
||||
updatedLabel: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<section id="content-privacy">
|
||||
<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">Rechtstexte</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Datenschutzerklärung</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Großes Textfeld für den kompletten Inhalt. Die Landingpage übernimmt denselben Text.</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-3">
|
||||
<ShieldCheck class="h-6 w-6 text-violet-500" />
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="$emit('open-preview')">
|
||||
<Eye class="h-4 w-4" />
|
||||
Preview öffnen
|
||||
</Button>
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Datenschutz speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-[24px] border border-violet-100 bg-gradient-to-br from-violet-50 via-white to-[#f8eefb] p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Aktualisiert von</p>
|
||||
<p class="mt-2 text-lg font-semibold text-violet-900">{{ updatedBy || 'Unbekannt' }}</p>
|
||||
</div>
|
||||
<div class="rounded-[24px] border border-violet-100 bg-white p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Zeitpunkt</p>
|
||||
<p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="mt-6 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz Inhalt</span>
|
||||
<textarea
|
||||
v-model="form.privacyPolicyContent"
|
||||
rows="24"
|
||||
class="w-full rounded-[28px] border border-violet-200 bg-[#fcfbff] px-5 py-4 text-sm leading-7 text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Datenschutztext..."
|
||||
/>
|
||||
</label>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Eye, Save, ShieldCheck } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
updatedBy: string | null
|
||||
updatedLabel: string
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'open-preview': []
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('Datenschutzerklärung')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Bereiche</p>
|
||||
<div class="mt-4 grid gap-2">
|
||||
<a
|
||||
v-for="section in sections"
|
||||
:key="section.href"
|
||||
:href="section.href"
|
||||
:class="section.primary ? primaryClasses : secondaryClasses"
|
||||
>
|
||||
{{ section.label }}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
type SectionLink = {
|
||||
href: string
|
||||
label: string
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
sections: SectionLink[]
|
||||
}>()
|
||||
|
||||
const primaryClasses =
|
||||
'rounded-2xl border border-violet-100 bg-violet-50/70 px-4 py-3 text-sm font-semibold text-violet-800 no-underline transition hover:bg-violet-100'
|
||||
const secondaryClasses =
|
||||
'rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm font-semibold text-slate-700 no-underline transition hover:bg-violet-50'
|
||||
</script>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<Card id="content-socials" class="p-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Community</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Social Links</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Plattform, Label und URL werden auf der Landingpage als Social Icons angezeigt.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:justify-end">
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addSocialLink">
|
||||
<Plus class="h-4 w-4" />
|
||||
Link hinzufügen
|
||||
</Button>
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Socials speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-4">
|
||||
<p v-if="iconUploadError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ iconUploadError }}</p>
|
||||
<div v-for="(social, index) in form.socialLinks" :key="`${social.platform}-${index}`" class="rounded-[28px] border border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-white p-5 shadow-[0_16px_42px_rgba(124,92,255,0.08)]">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-2xl bg-violet-100 text-sm font-black text-violet-700">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-violet-500">Social Link</p>
|
||||
<h3 class="text-lg font-semibold text-slate-900">{{ social.label || 'Neuer Link' }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="h-10 w-10 rounded-2xl border border-rose-100 bg-rose-50 p-0 text-rose-600 shadow-none hover:bg-rose-100" aria-label="Social Link entfernen" @click="removeSocialLink(index)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)_300px]">
|
||||
<label class="space-y-2">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input v-model="social.label" type="text" class="h-11 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" placeholder="Twitch" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform auswählen</span>
|
||||
<select :value="selectedSocialIconValue(social)" class="h-11 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" @change="handleSocialIconSelection($event, index)">
|
||||
<optgroup v-for="group in SOCIAL_ICON_OPTION_GROUPS" :key="group.label" :label="group.label">
|
||||
<option v-for="option in group.options" :key="option.key" :value="option.key">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<option value="custom">Andere Plattform / eigenes Icon</option>
|
||||
</select>
|
||||
<span class="block text-xs text-slate-400">Bekannte Plattformen nutzen automatisch Bibliotheks-Icons. Neue oder eingestellte Plattformen bleiben über Custom-Key plus Upload möglich.</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Custom Icon</span>
|
||||
<label class="group flex h-11 min-w-0 cursor-pointer items-center justify-between gap-3 rounded-2xl border border-dashed border-violet-300 bg-white px-3 text-sm font-semibold text-violet-700 transition hover:border-violet-500 hover:bg-violet-50">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-xl bg-violet-100 text-violet-600">
|
||||
<Upload class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="truncate">{{ isUploadedIcon(social.icon) ? 'Icon ersetzen' : 'Icon hochladen' }}</span>
|
||||
</span>
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" class="sr-only" @change="handleSocialIconUpload($event, index)" />
|
||||
</label>
|
||||
</div>
|
||||
<label v-if="selectedSocialIconValue(social) === 'custom'" class="space-y-2 xl:col-span-3">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Andere Plattform / eigener Key</span>
|
||||
<input v-model="social.platform" type="text" class="h-11 w-full 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" placeholder="z.B. cake, booth, neue-plattform" />
|
||||
<span class="block text-xs text-slate-400">Für neue oder nicht mehr gepflegte Plattformen: Key speichern, eigenes Icon hochladen oder Stern-Fallback nutzen.</span>
|
||||
</label>
|
||||
<label class="space-y-2 xl:col-span-3">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">URL</span>
|
||||
<input v-model="social.url" type="url" class="h-11 w-full 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" placeholder="https://..." />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="hasSocialIconPreview(social)" class="mt-4 grid gap-4 rounded-[22px] border border-violet-100 bg-white/82 p-4">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Icon Preview</p>
|
||||
<p class="mt-1 text-xs text-slate-500">Aktives Icon: {{ socialIconModeLabel(social) }}. Unbekannte Plattformen bleiben verlinkt und nutzen ein Fallback, bis du ein Custom Icon hochlädst.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="flex items-center justify-between gap-4 rounded-[18px] border border-violet-100 bg-violet-50/60 p-3">
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-[11px] bg-[#f1ecfb]">
|
||||
<img v-if="isUploadedIcon(social.icon)" :src="social.icon" :alt="`${social.label || social.platform} Host Icon Preview`" class="h-[22px] w-[22px] object-contain" />
|
||||
<svg v-else-if="socialSimpleIconPath(social)" class="h-[22px] w-[22px]" viewBox="0 0 24 24" :fill="socialSimpleIconColor(social)"><path :d="socialSimpleIconPath(social)" /></svg>
|
||||
<span v-else class="text-lg font-black text-violet-600">✦</span>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-700">Host Größe</span>
|
||||
</span>
|
||||
<input v-model="social.showOnHost" type="checkbox" class="h-4 w-4 rounded border-violet-300 text-violet-600 focus:ring-violet-300" />
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-4 rounded-[18px] border border-violet-100 bg-white p-3">
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="grid h-[46px] w-[46px] place-items-center rounded-[13px] border border-[#e9e0f8] bg-white shadow-[0_4px_12px_rgba(124,86,196,0.08)]">
|
||||
<img v-if="isUploadedIcon(social.icon)" :src="social.icon" :alt="`${social.label || social.platform} Community Icon Preview`" class="h-6 w-6 object-contain" />
|
||||
<svg v-else-if="socialSimpleIconPath(social)" class="h-6 w-6" viewBox="0 0 24 24" :fill="socialSimpleIconColor(social)"><path :d="socialSimpleIconPath(social)" /></svg>
|
||||
<span v-else class="text-xl font-black text-violet-600">✦</span>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-700">Community Größe</span>
|
||||
</span>
|
||||
<input v-model="social.showOnCommunity" type="checkbox" class="h-4 w-4 rounded border-violet-300 text-violet-600 focus:ring-violet-300" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-slate-500 lg:col-span-4">
|
||||
<span>PNG, JPG, WebP oder SVG · max. 256 KB · max. 512 x 512 px</span>
|
||||
<button v-if="social.icon" type="button" class="font-semibold text-rose-500 transition hover:text-rose-700" @click="clearSocialIcon(index)">Icon entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Save, Trash2, Upload } from '@lucide/vue'
|
||||
|
||||
import { SOCIAL_ICON_OPTION_GROUPS } from '../../lib/socialIcons'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
iconUploadError: string
|
||||
addSocialLink: () => void
|
||||
removeSocialLink: (index: number) => void
|
||||
isUploadedIcon: (icon: string) => boolean
|
||||
selectedSocialIconValue: (social: SocialLinkForm) => string
|
||||
handleSocialIconSelection: (event: Event, index: number) => void
|
||||
hasSocialIconPreview: (social: SocialLinkForm) => boolean
|
||||
socialIconModeLabel: (social: SocialLinkForm) => string
|
||||
socialSimpleIconPath: (social: SocialLinkForm) => string
|
||||
socialSimpleIconColor: (social: SocialLinkForm) => string
|
||||
handleSocialIconUpload: (event: Event, index: number) => Promise<void>
|
||||
clearSocialIcon: (index: number) => void
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('Social Links')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
activities: Array<{ label: string; age: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-7">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitäten</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Was gerade passiert ist</h2>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert für den schnellen Blick.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div
|
||||
v-for="activity in activities"
|
||||
:key="activity.label"
|
||||
class="rounded-[24px] border border-violet-100 bg-violet-50/60 px-5 py-5"
|
||||
>
|
||||
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ activity.age }}</p>
|
||||
</div>
|
||||
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Noch keine aktuellen Audit-Aktivitäten vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
defineProps<{
|
||||
operationChecks: Array<{ label: string; value: number; to: string; state: string; note: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-4 lg:grid-cols-3">
|
||||
<RouterLink
|
||||
v-for="check in operationChecks"
|
||||
:key="check.label"
|
||||
:to="check.to"
|
||||
class="rounded-[24px] border bg-white/85 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)] transition hover:-translate-y-0.5 hover:bg-violet-50/50"
|
||||
:class="{
|
||||
'border-emerald-100': check.state === 'ok',
|
||||
'border-amber-100': check.state === 'warn',
|
||||
'border-rose-100': check.state === 'danger',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{{ check.label }}</p>
|
||||
<strong class="mt-3 block text-3xl" :class="check.state === 'danger' ? 'text-rose-700' : check.state === 'warn' ? 'text-amber-700' : 'text-emerald-700'">
|
||||
{{ check.value }}
|
||||
</strong>
|
||||
<p class="mt-2 text-sm leading-5 text-slate-500">{{ check.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
metricCards: Array<{ label: string; value: number; note: string; source: string; icon: unknown }>
|
||||
liveSummary: string
|
||||
liveStatusBadge: string
|
||||
openRiskCount: number
|
||||
openReviewCount: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 bg-white/80 p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-violet-500">Live-Lage</p>
|
||||
<p class="mt-1 max-w-3xl text-sm leading-5 text-slate-600">
|
||||
{{ liveSummary }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex shrink-0 items-center rounded-xl border px-3 py-2 text-xs font-bold"
|
||||
:class="openRiskCount > 0 ? 'border-rose-200 bg-rose-50 text-rose-700' : openReviewCount > 0 ? 'border-amber-200 bg-amber-50 text-amber-700' : 'border-emerald-200 bg-emerald-50 text-emerald-700'"
|
||||
>
|
||||
{{ liveStatusBadge }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 p-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="metric in metricCards"
|
||||
:key="metric.label"
|
||||
class="rounded-2xl border border-violet-100 bg-white/90 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500">{{ metric.label }}</p>
|
||||
<strong class="mt-2 block text-2xl text-violet-900">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-700">
|
||||
<component :is="metric.icon" class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl border border-violet-100 bg-violet-50/50 px-3 py-2">
|
||||
<p class="text-xs font-semibold leading-5 text-slate-700">{{ metric.note }}</p>
|
||||
<p class="mt-0.5 text-[11px] text-slate-500">Quelle: {{ metric.source }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { Clock3 } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
priorityActions: Array<{ label: string; value: number; to: string; hint: string; icon: unknown; tone: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Schnellzugriffe</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Was zuerst?</h2>
|
||||
</div>
|
||||
<Clock3 class="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
<RouterLink
|
||||
v-for="item in priorityActions"
|
||||
:key="item.label"
|
||||
:to="item.to"
|
||||
class="group block rounded-[22px] border border-violet-100 bg-white/85 p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/70"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl"
|
||||
:class="{
|
||||
'bg-violet-100 text-violet-700': item.tone === 'violet',
|
||||
'bg-rose-100 text-rose-700': item.tone === 'rose',
|
||||
'bg-amber-100 text-amber-700': item.tone === 'amber',
|
||||
'bg-emerald-100 text-emerald-700': item.tone === 'emerald',
|
||||
}"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-800">{{ item.label }}</p>
|
||||
<p class="truncate text-sm text-slate-500">{{ item.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<strong class="rounded-full border border-violet-100 bg-white px-3 py-1 text-violet-800">{{ item.value }}</strong>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
totalCategoryVotes: number
|
||||
maxCategoryVotes: number
|
||||
topCategories: Array<{ category: string; votes: number }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-7">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kategorie-Performance</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Top Kategorien nach Stimmen</h2>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">{{ totalCategoryVotes.toLocaleString('de-DE') }} Stimmen in den Top-Kategorien</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="(category, index) in topCategories"
|
||||
:key="category.category"
|
||||
class="rounded-[24px] border border-violet-100 bg-white/90 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">#{{ index + 1 }}</p>
|
||||
<h3 class="mt-1 font-semibold text-slate-800">{{ category.category }}</h3>
|
||||
</div>
|
||||
<strong class="text-lg text-violet-800">{{ Number(category.votes).toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
|
||||
<div
|
||||
class="h-3 rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]"
|
||||
:style="{ width: `${(category.votes / maxCategoryVotes) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { BarChart3 } from '@lucide/vue'
|
||||
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
year: number
|
||||
yearTotals: Array<{ label: string; value: number; note: string; icon: unknown }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Jahreszahlen</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Gesamtmetriken {{ year }}</h2>
|
||||
</div>
|
||||
<BarChart3 class="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="item in yearTotals"
|
||||
:key="item.label"
|
||||
class="rounded-[22px] border border-violet-100 bg-white/90 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ item.label }}</p>
|
||||
<strong class="mt-2 block text-3xl text-violet-900">{{ item.value.toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="grid h-9 w-9 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<component :is="item.icon" class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-5 text-slate-500">{{ item.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { Sparkles, X } from '@lucide/vue'
|
||||
|
||||
import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue'
|
||||
import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
|
||||
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
|
||||
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
|
||||
import { useAdminReviewsManager } from './useAdminReviewsManager'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const {
|
||||
reviewSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
reviewForms,
|
||||
seasonDetail,
|
||||
reviewFilter,
|
||||
categoryFilter,
|
||||
selectedNominationId,
|
||||
candidatePlatformOptions,
|
||||
filteredNominations,
|
||||
selectedNomination,
|
||||
reviewStats,
|
||||
reviewedNominations,
|
||||
categoryOptions,
|
||||
selectedCandidateCollision,
|
||||
selectedRelatedPendingNominations,
|
||||
selectedNominationSignalSummary,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
selectedPlatformValue,
|
||||
handlePlatformSelection,
|
||||
extractNominationStreamUrl,
|
||||
} = useAdminReviewsManager()
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.open) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="absolute inset-0 bg-violet-950/35 backdrop-blur-sm" @click="closeModal" />
|
||||
|
||||
<section class="relative z-10 flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col overflow-hidden rounded-[28px] border border-violet-200/80 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]">
|
||||
<header class="flex items-start justify-between gap-4 border-b border-violet-100 bg-[linear-gradient(135deg,#f7f2ff,#fff7ed)] px-5 py-4 sm:px-6">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<Sparkles class="h-4.5 w-4.5" />
|
||||
</span>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Review-Fokus</p>
|
||||
</div>
|
||||
<h2 class="mt-3 text-2xl font-bold text-slate-950">Nominierung entscheiden</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
Die Nominierungsliste bleibt scanbar; Entscheidungen laufen hier konzentriert mit Queue, Prüfung und Kandidatenübernahme.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 shrink-0 place-items-center rounded-full text-slate-400 transition hover:bg-white/80 hover:text-violet-700"
|
||||
aria-label="Review-Dialog schließen"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 overflow-y-auto bg-white">
|
||||
<AdminReviewsQueueHeader
|
||||
v-model:review-filter="reviewFilter"
|
||||
v-model:category-filter="categoryFilter"
|
||||
:total-pending="seasonDetail.pendingNominations.length"
|
||||
:visible-count="filteredNominations.length"
|
||||
:review-stats="reviewStats"
|
||||
:category-options="categoryOptions"
|
||||
/>
|
||||
|
||||
<div class="space-y-4 p-4 sm: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(300px,0.8fr)_minmax(0,1.2fr)]">
|
||||
<AdminReviewsQueueList
|
||||
:nominations="filteredNominations"
|
||||
:total-pending="seasonDetail.pendingNominations.length"
|
||||
:selected-nomination-id="selectedNominationId"
|
||||
@select="selectedNominationId = $event"
|
||||
/>
|
||||
|
||||
<AdminReviewDecisionPanel
|
||||
:nomination="selectedNomination"
|
||||
:review-saving="reviewSaving"
|
||||
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
|
||||
:candidate-platform-options="candidatePlatformOptions"
|
||||
:selected-candidate-collision="selectedCandidateCollision"
|
||||
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
||||
:signal-summary="selectedNominationSignalSummary"
|
||||
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
||||
:can-approve-selected="canApproveSelected"
|
||||
:selected-platform-value="selectedPlatformValue"
|
||||
@platform-change="handlePlatformSelection"
|
||||
@approve="approveNomination"
|
||||
@reject="rejectNomination"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details class="rounded-[24px] border border-violet-100 bg-violet-50/40">
|
||||
<summary class="cursor-pointer px-5 py-4 text-sm font-semibold text-violet-900">
|
||||
Letzte Entscheidungen anzeigen
|
||||
</summary>
|
||||
<div class="border-t border-violet-100 p-5">
|
||||
<AdminReviewsHistorySection
|
||||
:reviewed-nominations="reviewedNominations"
|
||||
:reviewed-total="seasonDetail.reviewedNominations.length"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound, Loader2, LockKeyhole, Save, ShieldCheck, Wrench } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
||||
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
form: AdminOperationalSettingsForm
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
error: string
|
||||
success: string
|
||||
demoPassword: string
|
||||
demoPasswordHint: string
|
||||
demoPasswordSet: boolean
|
||||
demoManagedByDatabase: boolean
|
||||
demoCredentialsComplete: boolean
|
||||
summary: AdminSettingsStatusSummary[]
|
||||
dirty: boolean
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
'update:demoPassword': [value: string]
|
||||
}>()
|
||||
|
||||
function readInput(event: Event) {
|
||||
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
||||
}
|
||||
|
||||
function toneClasses(tone: AdminSettingsTone) {
|
||||
if (tone === 'good') {
|
||||
return 'border-emerald-100 bg-emerald-50/55 text-emerald-800'
|
||||
}
|
||||
|
||||
if (tone === 'warning') {
|
||||
return 'border-amber-100 bg-amber-50/70 text-amber-800'
|
||||
}
|
||||
|
||||
if (tone === 'danger') {
|
||||
return 'border-rose-100 bg-rose-50/70 text-rose-800'
|
||||
}
|
||||
|
||||
return 'border-violet-100 bg-violet-50/60 text-violet-800'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/60 p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Demo & Wartung</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Öffentlichen Zugriff steuern</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Demo-Schutz und Wartungsmodus sind echte Betriebs-Schalter. Änderungen werden in der Datenbank gespeichert und sofort für die Public-App verwendet.
|
||||
</p>
|
||||
</div>
|
||||
<Button :disabled="loading || saving || !canManage || !dirty" class="gap-2 rounded-2xl px-5" @click="emit('save')">
|
||||
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
|
||||
<LockKeyhole v-else-if="!canManage" class="h-4 w-4" />
|
||||
<Save v-else class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : !canManage ? 'Nur Owner' : dirty ? 'Zugriff speichern' : 'Gespeichert' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-3 p-6 text-sm font-semibold text-violet-700">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Lade Demo- und Wartungseinstellungen ...
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-5 p-5">
|
||||
<p
|
||||
v-if="!canManage"
|
||||
class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-semibold leading-6 text-slate-700"
|
||||
>
|
||||
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
|
||||
Demo-Zugang und Wartungsmodus sind Owner-Controls. Du kannst den Status ansehen, aber nicht bearbeiten.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="dirty"
|
||||
class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
|
||||
>
|
||||
Ungespeicherte Änderungen vorhanden. Beim Verlassen der Seite fragt das Panel nach.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
class="rounded-2xl border px-4 py-3"
|
||||
:class="toneClasses(item.tone)"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">{{ item.label }}</p>
|
||||
<strong class="mt-1 block text-lg text-slate-950">{{ item.value }}</strong>
|
||||
<p class="mt-1 text-xs leading-5">{{ item.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-2">
|
||||
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex gap-3">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<KeyRound class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Demo Login</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">Landingpage temporär sperren</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Für Demo-Deployments oder nicht öffentliche Tests. Danach kann der Schutz hier wieder aus.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminSettingsToggle
|
||||
v-model="form.demoLoginEnabled"
|
||||
label="Demo Login aktivieren"
|
||||
:disabled="saving || !canManage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="form.demoLoginEnabled && !demoCredentialsComplete"
|
||||
class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
|
||||
>
|
||||
Zum Aktivieren fehlen noch Login, Twitch-ID, Anzeigename oder ein gesetztes Passwort.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Login</span>
|
||||
<input v-model="form.demoLoginIdentifier" :disabled="saving || !canManage" type="text" autocomplete="username" placeholder="E-Mail oder Username" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||
<span class="mt-2 block text-xs leading-5 text-slate-500">E-Mail, Username oder dieselbe Kennung wie die Twitch-ID.</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Passwort setzen</span>
|
||||
<input :value="demoPassword" :disabled="saving || !canManage" type="password" autocomplete="new-password" placeholder="Leer lassen = behalten" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" @input="emit('update:demoPassword', readInput($event))" />
|
||||
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ demoPasswordHint }}</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Twitch-ID</span>
|
||||
<input v-model="form.demoLoginTwitchUserId" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 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">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input v-model="form.demoLoginDisplayName" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span class="rounded-full bg-violet-50 px-3 py-1 text-violet-700">{{ demoManagedByDatabase ? 'Quelle: Datenbank' : 'Quelle: Config' }}</span>
|
||||
<span class="rounded-full px-3 py-1" :class="demoPasswordSet ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||
{{ demoPasswordSet ? 'Passwort vorhanden' : 'Passwort fehlt' }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[26px] border p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]" :class="form.maintenanceModeEnabled ? 'border-amber-200 bg-amber-50/70' : 'border-violet-100 bg-white/85'">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex gap-3">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl" :class="form.maintenanceModeEnabled ? 'bg-amber-100 text-amber-700' : 'bg-violet-100 text-violet-700'">
|
||||
<Wrench class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em]" :class="form.maintenanceModeEnabled ? 'text-amber-600' : 'text-violet-500'">Wartungsmodus</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">Sternenpause anzeigen</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Besucher landen auf der Wartungsseite. Login und Admin bleiben erreichbar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminSettingsToggle
|
||||
v-model="form.maintenanceModeEnabled"
|
||||
label="Wartungsmodus aktivieren"
|
||||
tone="amber"
|
||||
:disabled="saving || !canManage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-4">
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel der Wartungsseite</span>
|
||||
<input v-model="form.maintenanceTitle" :disabled="saving || !canManage" type="text" maxlength="120" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 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">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Hinweistext</span>
|
||||
<textarea v-model="form.maintenanceMessage" :disabled="saving || !canManage" rows="4" maxlength="600" class="mt-2 w-full resize-none rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold leading-6 text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"></textarea>
|
||||
</label>
|
||||
<RouterLink to="/maintenance?preview=true" class="inline-flex h-11 items-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-violet-700 transition hover:bg-violet-50">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
Wartungsseite ansehen
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="error || success" class="space-y-3">
|
||||
<p v-if="error" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ error }}</p>
|
||||
<p v-else class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ success }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -1,50 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { Sparkles } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description: string
|
||||
/** Dekoratives Icon oben rechts – analog zu den Frontend-Bannern */
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.eyebrow ?? props.title ?? 'Admin')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="relative overflow-hidden rounded-[32px] border border-violet-200/60 bg-[linear-gradient(135deg,#ece2ff_0%,#f6ecff_42%,#fff2dd_100%)] px-7 py-9 shadow-[0_24px_60px_rgba(124,92,255,0.12)] sm:px-12 sm:py-11">
|
||||
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
|
||||
|
||||
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
|
||||
<g fill="#f6b938" opacity="0.7">
|
||||
<circle cx="74%" cy="24%" r="3" />
|
||||
<circle cx="92%" cy="62%" r="2.5" />
|
||||
<circle cx="63%" cy="78%" r="2.5" />
|
||||
</g>
|
||||
<g fill="#a78bff" opacity="0.6">
|
||||
<circle cx="83%" cy="40%" r="2.5" />
|
||||
<circle cx="69%" cy="50%" r="2" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-if="icon"
|
||||
class="pointer-events-none absolute -right-6 top-1/2 hidden -translate-y-1/2 sm:block"
|
||||
>
|
||||
<div class="grid h-40 w-40 place-items-center rounded-full bg-white/35 text-amber-400/80 ring-1 ring-white/60 backdrop-blur-sm">
|
||||
<component :is="icon" class="h-[4.5rem] w-[4.5rem]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-2xl space-y-4">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-600 shadow-sm backdrop-blur">
|
||||
<Sparkles class="h-3.5 w-3.5 text-amber-500" />
|
||||
{{ eyebrow ?? 'Admin' }}
|
||||
<section
|
||||
class="rounded-2xl border border-violet-100 bg-white/85 px-4 py-3 shadow-[0_12px_32px_rgba(124,92,255,0.07)]"
|
||||
:aria-label="label"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span
|
||||
v-if="icon"
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-100 bg-violet-50 text-violet-600"
|
||||
>
|
||||
<component :is="icon" class="h-4 w-4" />
|
||||
</span>
|
||||
<h1 class="font-[Cormorant_Garamond] text-4xl leading-[1.02] text-violet-800 sm:text-5xl">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="max-w-xl text-sm leading-6 text-slate-600 sm:text-base">{{ description }}</p>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[11px] font-bold uppercase tracking-[0.18em] text-violet-600">
|
||||
{{ label }}
|
||||
</p>
|
||||
<p v-if="description" class="mt-1 max-w-4xl text-sm leading-5 text-slate-500">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Trash2 } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
nomination: AdminNominationReviewItem | null
|
||||
reviewSaving: number | null
|
||||
reviewForm: {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
reviewNote: string
|
||||
} | null
|
||||
candidatePlatformOptions: Array<{ key: string; label: string }>
|
||||
selectedCandidateCollision: AdminCandidateItem | null
|
||||
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
|
||||
signalSummary?: {
|
||||
submissions: number
|
||||
uniqueSubmitters: number
|
||||
platforms: string[]
|
||||
} | null
|
||||
streamUrl?: string
|
||||
canApproveSelected: boolean
|
||||
selectedPlatformValue: (platform: string) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'platform-change': [event: Event]
|
||||
approve: [nominationId: number]
|
||||
reject: [nominationId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
|
||||
ID {{ nomination.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
|
||||
<a
|
||||
:href="streamUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mt-2 inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
|
||||
>
|
||||
<span class="truncate">{{ streamUrl }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Einreichungen</p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.submissions }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Eindeutige User</p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.uniqueSubmitters }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Plattformen</p>
|
||||
<strong class="mt-1 block truncate text-sm text-violet-900">{{ signalSummary.platforms.join(', ') || 'keine' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input
|
||||
v-model="reviewForm.displayName"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Anzeigename"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||
<input
|
||||
v-model="reviewForm.channelSlug"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="channel"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||
<select
|
||||
:value="selectedPlatformValue(reviewForm.platform)"
|
||||
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"
|
||||
@change="emit('platform-change', $event)"
|
||||
>
|
||||
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
<option value="custom">Eigene Plattform</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
|
||||
<input
|
||||
v-model="reviewForm.platform"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="z. B. Cake, Booth, neue Plattform"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden. Übernimm nur, wenn es wirklich ein separater Kandidat ist; Alias-/Merge-Pflege gehört danach in Kandidaten.
|
||||
</p>
|
||||
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
|
||||
<ul class="mt-2 space-y-1">
|
||||
<li v-for="related in selectedRelatedPendingNominations" :key="related.id">
|
||||
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.reviewNote || related.streamUrl || 'ohne Notiz' }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
Anzeigename, Handle und Plattform sind Pflicht, damit der Kandidat später im Voting eindeutig erscheint.
|
||||
</p>
|
||||
<label class="mt-3 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Moderationsnotiz</span>
|
||||
<textarea
|
||||
v-model="reviewForm.reviewNote"
|
||||
rows="3"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Warum wurde übernommen oder verworfen?"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="emit('reject', nomination.id)">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||
</Button>
|
||||
<Button :disabled="reviewSaving === nomination.id || !canApproveSelected" @click="emit('approve', nomination.id)">
|
||||
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
reviewedNominations: AdminNominationReviewItem[]
|
||||
reviewedTotal: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-[26px] border border-violet-100 bg-violet-50/50 p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Letzte Entscheidungen</p>
|
||||
<h3 class="mt-1 text-lg font-bold text-slate-900">Review-Historie</h3>
|
||||
</div>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{{ reviewedTotal }} entschieden
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div v-for="nomination in reviewedNominations" :key="`reviewed-${nomination.id}`" class="rounded-2xl border border-violet-100 bg-white/90 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-900">{{ nomination.candidateText }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ nomination.categoryName }} · {{ nomination.submittedByTwitchId }}
|
||||
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="nomination.status === 'approved' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'">
|
||||
{{ nomination.status === 'approved' ? 'übernommen' : 'verworfen' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="nomination.reviewNote" class="mt-3 text-sm leading-6 text-slate-600">{{ nomination.reviewNote }}</p>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
{{ nomination.reviewedByTwitchId || 'Admin' }} · {{ nomination.reviewedAt ? new Date(nomination.reviewedAt).toLocaleString('de-DE') : 'ohne Zeitstempel' }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="reviewedNominations.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-6 text-sm text-slate-500">
|
||||
Noch keine gespeicherten Review-Entscheidungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
reviewFilter: string
|
||||
categoryFilter: number | null
|
||||
totalPending: number
|
||||
visibleCount: number
|
||||
reviewStats: Array<{ label: string; value: number }>
|
||||
categoryOptions: Array<{ id: number; label: string; count: number }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:reviewFilter': [value: string]
|
||||
'update:categoryFilter': [value: number | null]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-violet-100 bg-white/75 p-6">
|
||||
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Offene Nominierungen</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
|
||||
<div v-for="stat in reviewStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<label class="relative block">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
:value="reviewFilter"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Nach Kategorie, Kandidat oder Nutzer suchen"
|
||||
@input="emit('update:reviewFilter', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
{{ visibleCount }} / {{ totalPending }} sichtbar
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="categoryFilter === null ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="emit('update:categoryFilter', null)"
|
||||
>
|
||||
Alle Kategorien
|
||||
</button>
|
||||
<button
|
||||
v-for="category in categoryOptions"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="categoryFilter === category.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="emit('update:categoryFilter', category.id)"
|
||||
>
|
||||
{{ category.label }} · {{ category.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
nominations: AdminNominationReviewItem[]
|
||||
totalPending: number
|
||||
selectedNominationId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [nominationId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
||||
<button
|
||||
v-for="nomination in nominations"
|
||||
:key="nomination.id"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border p-3 text-left transition"
|
||||
:class="selectedNominationId === nomination.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
|
||||
@click="emit('select', nomination.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700">
|
||||
{{ nomination.categoryName }}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
|
||||
ID {{ nomination.id }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
|
||||
<p class="mt-1 truncate text-sm text-slate-500">
|
||||
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<p v-if="totalPending === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine offenen Review-Fälle im aktuell gewählten Award-Jahr.
|
||||
</p>
|
||||
<p v-else-if="nominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine Review-Fälle passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, ExternalLink, Trash2 } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import type { AdminRiskFlag } from '../../types/awards'
|
||||
import type { RiskMetadataItem, RiskResolutionStatus } from './useAdminRiskManager'
|
||||
import { riskAgeLabel, riskSeverityClass } from './useAdminRiskManager'
|
||||
|
||||
defineProps<{
|
||||
riskFlag: AdminRiskFlag | null
|
||||
metadataItems: RiskMetadataItem[]
|
||||
decisionNote: string
|
||||
decisionNoteLength: number
|
||||
decisionReady: boolean
|
||||
saving: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:decisionNote': [value: string]
|
||||
decide: [riskFlagId: number, status: RiskResolutionStatus]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="riskFlag"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-semibold" :class="riskSeverityClass(riskFlag.severity)">
|
||||
{{ riskFlag.severity }}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{{ riskAgeLabel(riskFlag) }}
|
||||
</span>
|
||||
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
{{ riskFlag.source }} · {{ riskFlag.type }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-xl font-bold text-slate-900">{{ riskFlag.summary }}</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
{{ riskFlag.twitchUserId || 'unbekannter User' }} · {{ riskFlag.createdFromIp }} · {{ new Date(riskFlag.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
|
||||
Risk Flag #{{ riskFlag.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||
<p class="text-xs font-semibold text-slate-500">Metadaten</p>
|
||||
<dl v-if="metadataItems.length" class="mt-3 space-y-2">
|
||||
<div
|
||||
v-for="item in metadataItems"
|
||||
:key="item.key"
|
||||
class="rounded-xl bg-white/80 px-3 py-2"
|
||||
>
|
||||
<dt class="text-xs font-semibold text-slate-400">{{ item.key }}</dt>
|
||||
<dd class="mt-1 break-words text-sm text-slate-700">{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-else class="mt-3 rounded-xl border border-dashed border-violet-100 px-3 py-4 text-sm text-slate-500">
|
||||
Keine zusätzlichen Metadaten hinterlegt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-2xl border border-violet-100 bg-white p-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500">Betroffene Bereiche</p>
|
||||
<div v-if="riskFlag.entityLinks.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<RouterLink
|
||||
v-for="link in riskFlag.entityLinks"
|
||||
:key="`${link.entityType}-${link.entityId}-${link.to}`"
|
||||
:to="link.to"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-violet-100 bg-violet-50 px-3 py-2 text-sm font-semibold text-violet-800 transition hover:border-violet-200 hover:bg-violet-100"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p v-else class="mt-3 rounded-xl border border-dashed border-violet-100 px-3 py-4 text-sm text-slate-500">
|
||||
Kein direkter Zielbereich hinterlegt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold text-slate-500">Entscheidungsnotiz</span>
|
||||
<textarea
|
||||
:value="decisionNote"
|
||||
rows="7"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Kurz festhalten, was geprüft wurde und warum du so entscheidest."
|
||||
@input="emit('update:decisionNote', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<p class="mt-2 text-xs" :class="decisionReady ? 'text-slate-500' : 'text-rose-600'">
|
||||
{{ decisionNoteLength }}/500 Zeichen · Mindestens 3 Zeichen für erledigt/verworfen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button
|
||||
:disabled="saving === riskFlag.id || !decisionReady"
|
||||
variant="secondary"
|
||||
@click="emit('decide', riskFlag.id, 'dismissed')"
|
||||
>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{{ saving === riskFlag.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="saving === riskFlag.id || !decisionReady"
|
||||
@click="emit('decide', riskFlag.id, 'resolved')"
|
||||
>
|
||||
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||
{{ saving === riskFlag.id ? 'Speichert ...' : 'Erledigt markieren' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="rounded-[26px] border border-dashed border-violet-100 bg-white/70 p-8 text-sm text-slate-500">
|
||||
Keine offene Auswahl. Sobald ein Risiko-Hinweis auftaucht, erscheint hier die Detailprüfung.
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, RotateCcw } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import type { AdminRiskFlag } from '../../types/awards'
|
||||
import type { RiskResolutionStatus } from './useAdminRiskManager'
|
||||
import { riskSeverityClass, riskStatusLabel } from './useAdminRiskManager'
|
||||
|
||||
defineProps<{
|
||||
riskHistory: AdminRiskFlag[]
|
||||
totalHistory: number
|
||||
historyStatusFilter: 'all' | 'resolved' | 'dismissed'
|
||||
historyStatusFilters: Array<{ key: 'all' | 'resolved' | 'dismissed'; label: string; count: number }>
|
||||
historyStats: Array<{ label: string; value: number }>
|
||||
saving: number | null
|
||||
page: number
|
||||
pageLabel: string
|
||||
hasPrevious: boolean
|
||||
hasMore: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:historyStatusFilter': [value: 'all' | 'resolved' | 'dismissed']
|
||||
decide: [riskFlagId: number, status: RiskResolutionStatus]
|
||||
page: [page: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-violet-100 bg-violet-50/25 p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Entscheidungsprotokoll</h2>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Geprüfte Hinweise mit Notiz, Reviewer und Reopen-Möglichkeit.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in historyStatusFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-2 text-xs font-semibold transition"
|
||||
:class="historyStatusFilter === filter.key ? 'border-violet-200 bg-white text-violet-800' : 'border-violet-100 bg-violet-50 text-slate-600 hover:bg-white'"
|
||||
@click="emit('update:historyStatusFilter', filter.key)"
|
||||
>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-2 sm:grid-cols-3">
|
||||
<div v-for="stat in historyStats" :key="stat.label" class="rounded-2xl border border-violet-100 bg-white/80 px-3 py-2">
|
||||
<p class="text-xs font-semibold text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
<article
|
||||
v-for="flag in riskHistory"
|
||||
:key="flag.id"
|
||||
class="rounded-[22px] border border-violet-100 bg-white/90 p-4 shadow-sm shadow-violet-100/40"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
:class="flag.status === 'resolved' ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'"
|
||||
>
|
||||
{{ riskStatusLabel(flag.status) }}
|
||||
</span>
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-semibold" :class="riskSeverityClass(flag.severity)">
|
||||
{{ flag.severity }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-3 text-base font-semibold text-slate-950">{{ flag.summary }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
{{ flag.source }} · {{ flag.type }} · {{ flag.twitchUserId || 'unbekannter User' }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-3 text-violet-700 shadow-none hover:bg-violet-100"
|
||||
:disabled="saving === flag.id"
|
||||
@click="emit('decide', flag.id, 'open')"
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
{{ saving === flag.id ? 'Öffnet ...' : 'Wieder öffnen' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 text-xs text-slate-500 sm:grid-cols-2">
|
||||
<p>
|
||||
<span class="block font-semibold text-slate-400">Entschieden von</span>
|
||||
{{ flag.reviewedByTwitchId || 'nicht protokolliert' }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="block font-semibold text-slate-400">Zeitpunkt</span>
|
||||
{{ flag.reviewedAt ? new Date(flag.reviewedAt).toLocaleString('de-DE') : 'nicht protokolliert' }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-4 rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3 text-sm text-slate-600">
|
||||
{{ flag.reviewNote || 'Keine Review-Notiz gespeichert.' }}
|
||||
</p>
|
||||
<div v-if="flag.entityLinks.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<RouterLink
|
||||
v-for="link in flag.entityLinks"
|
||||
:key="`${flag.id}-${link.entityType}-${link.entityId}-${link.to}`"
|
||||
:to="link.to"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-violet-100 bg-violet-50 px-3 py-2 text-xs font-semibold text-violet-800 transition hover:border-violet-200 hover:bg-violet-100"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="totalHistory === 0" class="rounded-[22px] border border-dashed border-violet-100 bg-white/70 px-5 py-6 text-sm text-slate-500">
|
||||
Noch keine entschiedenen Risikohinweise vorhanden.
|
||||
</p>
|
||||
<p v-else-if="riskHistory.length === 0" class="rounded-[22px] border border-dashed border-violet-100 bg-white/70 px-5 py-6 text-sm text-slate-500">
|
||||
Keine Historien-Einträge passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-white/75 px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 border border-violet-100"
|
||||
:disabled="!hasPrevious"
|
||||
@click="emit('page', page - 1)"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
<span class="text-sm font-semibold text-slate-500">{{ pageLabel }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 border border-violet-100"
|
||||
:disabled="!hasMore"
|
||||
@click="emit('page', page + 1)"
|
||||
>
|
||||
Weiter
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { RefreshCw, Search } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
riskFilter: string
|
||||
severityFilter: 'all' | 'high' | 'medium' | 'low'
|
||||
filteredCount: number
|
||||
totalOpen: number
|
||||
loadedLabel: string
|
||||
loading: boolean
|
||||
message: string
|
||||
error: string
|
||||
stats: Array<{ label: string; value: number; tone: string }>
|
||||
severityFilters: Array<{ key: 'all' | 'high' | 'medium' | 'low'; label: string; count: number }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:riskFilter': [value: string]
|
||||
'update:severityFilter': [value: 'all' | 'high' | 'medium' | 'low']
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
function statToneClass(tone: string) {
|
||||
if (tone === 'danger') return 'border-rose-100 bg-rose-50 text-rose-800'
|
||||
if (tone === 'warning') return 'border-amber-100 bg-amber-50 text-amber-800'
|
||||
if (tone === 'ok') return 'border-emerald-100 bg-emerald-50 text-emerald-800'
|
||||
return 'border-violet-100 bg-violet-50 text-violet-800'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-b border-violet-100 bg-white/70 p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-900">Risiko-Zentrale</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
Offene Hinweise priorisieren, Entscheidungsnotizen setzen und geprüfte Fälle nachvollziehbar protokollieren.
|
||||
</p>
|
||||
<p class="mt-1 text-xs font-semibold text-slate-400">
|
||||
Geladen: {{ loadedLabel }} · Quelle: /api/admin/risk-flags
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="rounded-2xl border border-violet-100 bg-violet-50 px-4 py-2 text-sm font-semibold text-violet-800">
|
||||
{{ filteredCount }} / {{ totalOpen }} sichtbar
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100"
|
||||
:disabled="loading"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
||||
{{ loading ? 'Lädt ...' : 'Aktualisieren' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="rounded-2xl border px-4 py-3"
|
||||
:class="statToneClass(stat.tone)"
|
||||
>
|
||||
<p class="text-xs font-semibold text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="mt-1 block text-2xl">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<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
|
||||
:value="riskFilter"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Typ, User, IP, Summary oder Metadaten filtern"
|
||||
@input="emit('update:riskFilter', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in severityFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-2 text-xs font-semibold transition"
|
||||
:class="severityFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="emit('update:severityFilter', filter.key)"
|
||||
>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="message" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{{ message }}
|
||||
</p>
|
||||
<p v-if="error" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ error }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, Clock3, ListChecks, Trash2, X } from '@lucide/vue'
|
||||
|
||||
import type { AdminRiskFlag } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import { riskAgeLabel, riskSeverityClass } from './useAdminRiskManager'
|
||||
|
||||
defineProps<{
|
||||
riskFlags: AdminRiskFlag[]
|
||||
totalOpen: number
|
||||
selectedRiskFlagId: number | null
|
||||
page: number
|
||||
pageLabel: string
|
||||
hasPrevious: boolean
|
||||
hasMore: boolean
|
||||
selectedBulkRiskFlagIds: number[]
|
||||
bulkReviewNote: string
|
||||
bulkSaving: boolean
|
||||
canBulkResolve: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [riskFlagId: number]
|
||||
page: [page: number]
|
||||
toggleBulk: [riskFlagId: number]
|
||||
selectVisibleLow: []
|
||||
clearBulk: []
|
||||
'update:bulkReviewNote': [value: string]
|
||||
bulkDecide: [status: 'resolved' | 'dismissed']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Offene Queue</h2>
|
||||
<p class="text-sm text-slate-500">Nach Severity und Alter prüfen.</p>
|
||||
</div>
|
||||
<span class="rounded-2xl border border-violet-100 bg-violet-50 px-3 py-1.5 text-sm font-semibold text-violet-800">
|
||||
{{ pageLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-violet-100 bg-white/85 p-3">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">Bulk Low Severity</p>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
{{ selectedBulkRiskFlagIds.length }} ausgewählt · nur offene Low-Hinweise
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 sm:flex-row lg:max-w-2xl">
|
||||
<input
|
||||
:value="bulkReviewNote"
|
||||
class="min-h-10 flex-1 rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Bulk-Notiz für niedrige Severity"
|
||||
@input="emit('update:bulkReviewNote', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="ghost" class="gap-2 border border-violet-100" @click="emit('selectVisibleLow')">
|
||||
<ListChecks class="h-4 w-4" />
|
||||
Seite
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" class="gap-2 border border-violet-100" @click="emit('clearBulk')">
|
||||
<X class="h-4 w-4" />
|
||||
Leeren
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" class="gap-2" :disabled="!canBulkResolve || bulkSaving" @click="emit('bulkDecide', 'dismissed')">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Verwerfen
|
||||
</Button>
|
||||
<Button size="sm" class="gap-2" :disabled="!canBulkResolve || bulkSaving" @click="emit('bulkDecide', 'resolved')">
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
Erledigt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
||||
<article
|
||||
v-for="flag in riskFlags"
|
||||
:key="flag.id"
|
||||
class="w-full rounded-2xl border p-4 transition"
|
||||
:class="selectedRiskFlagId === flag.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<label
|
||||
class="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-violet-100 bg-white"
|
||||
:class="flag.severity.toLowerCase() === 'low' ? 'cursor-pointer' : 'opacity-35'"
|
||||
:title="flag.severity.toLowerCase() === 'low' ? 'Für Bulk auswählen' : 'Bulk ist nur für Low-Severity aktiv'"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-violet-600"
|
||||
:disabled="flag.severity.toLowerCase() !== 'low'"
|
||||
:checked="selectedBulkRiskFlagIds.includes(flag.id)"
|
||||
@change="emit('toggleBulk', flag.id)"
|
||||
>
|
||||
</label>
|
||||
<div class="min-w-0">
|
||||
<button type="button" class="block w-full text-left" @click="emit('select', flag.id)">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold" :class="riskSeverityClass(flag.severity)">
|
||||
<AlertTriangle class="h-3.5 w-3.5" />
|
||||
{{ flag.severity }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2.5 py-1 text-xs font-semibold text-slate-600">
|
||||
<Clock3 class="h-3.5 w-3.5" />
|
||||
{{ riskAgeLabel(flag) }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-3 line-clamp-2 text-base font-semibold text-slate-900">{{ flag.summary }}</h3>
|
||||
<p class="mt-2 truncate text-sm text-slate-500">
|
||||
{{ flag.source }} · {{ flag.type }} · {{ flag.twitchUserId || 'unbekannter User' }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-xl bg-white px-2.5 py-1 text-xs font-semibold text-slate-500">
|
||||
ID {{ flag.id }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="totalOpen === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine offenen Risikohinweise vorhanden.
|
||||
</p>
|
||||
<p v-else-if="riskFlags.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine offenen Hinweise passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-white/75 px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 border border-violet-100"
|
||||
:disabled="!hasPrevious"
|
||||
@click="emit('page', page - 1)"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
<span class="text-sm font-semibold text-slate-500">{{ pageLabel }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 border border-violet-100"
|
||||
:disabled="!hasMore"
|
||||
@click="emit('page', page + 1)"
|
||||
>
|
||||
Weiter
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, SlidersHorizontal } from '@lucide/vue'
|
||||
|
||||
import type { AdminRiskRule } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
rules: AdminRiskRule[]
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateRule: [ruleKey: string, patch: Partial<AdminRiskRule>]
|
||||
save: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-violet-100 bg-white p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<SlidersHorizontal class="h-5 w-5 text-violet-700" />
|
||||
<h2 class="text-lg font-bold text-slate-900">Regel-Editor</h2>
|
||||
</div>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Thresholds, Zeitfenster und Severity ohne Code-Deploy anpassen.
|
||||
</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="loading || saving || rules.length === 0" @click="emit('save')">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Regeln speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mt-5 rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Risk-Regeln werden geladen.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 grid gap-3">
|
||||
<article
|
||||
v-for="rule in rules"
|
||||
:key="rule.key"
|
||||
class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4"
|
||||
>
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_120px_140px_150px_110px] lg:items-end">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
|
||||
</div>
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-semibold text-slate-500">Threshold</span>
|
||||
<input
|
||||
:value="rule.threshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
@input="emit('updateRule', rule.key, { threshold: Number(($event.target as HTMLInputElement).value) })"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-semibold text-slate-500">Fenster Min.</span>
|
||||
<input
|
||||
:value="rule.windowMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
@input="emit('updateRule', rule.key, { windowMinutes: Number(($event.target as HTMLInputElement).value) })"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-semibold text-slate-500">Severity</span>
|
||||
<select
|
||||
:value="rule.severity"
|
||||
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
@change="emit('updateRule', rule.key, { severity: ($event.target as HTMLSelectElement).value })"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex h-10 items-center gap-2 rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-violet-600"
|
||||
:checked="rule.enabled"
|
||||
@change="emit('updateRule', rule.key, { enabled: ($event.target as HTMLInputElement).checked })"
|
||||
>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="rules.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine Risk-Regeln verfügbar.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
import type { AdminSeasonCopyOption, AdminSeasonCreateForm } from './adminSeasonTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
createForm: AdminSeasonCreateForm
|
||||
creating: boolean
|
||||
canCreate: boolean
|
||||
copySourceOptions: AdminSeasonCopyOption[]
|
||||
createPublicReadinessIssues: string[]
|
||||
phasePresets: string[]
|
||||
onClose: () => void
|
||||
onCreate: () => Promise<void> | void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="props.open" title="Neues Award-Jahr anlegen" @close="props.onClose">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr</span>
|
||||
<input v-model.number="props.createForm.year" type="number" 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||
<input v-model="props.createForm.name" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
|
||||
<input v-model="props.createForm.showStreamUrl" type="url" 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" />
|
||||
</label>
|
||||
<label class="space-y-2 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Vorjahr kopieren</span>
|
||||
<NativeSelect v-model="props.createForm.copyStructureFromSeasonId" :options="props.copySourceOptions" />
|
||||
<span class="block text-sm leading-5 text-slate-500">
|
||||
Kopiert Kategorien, Gruppen, Slugs, Beschreibung, Sortierung und Nominierungs-Limits. Kandidaten und Gewinner bleiben leer.
|
||||
</span>
|
||||
</label>
|
||||
<fieldset class="grid gap-2 sm:col-span-2 sm:grid-cols-2">
|
||||
<legend class="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Startphase</legend>
|
||||
<button
|
||||
v-for="phase in props.phasePresets"
|
||||
:key="phase"
|
||||
type="button"
|
||||
class="rounded-2xl border px-4 py-3 text-left text-sm font-semibold transition"
|
||||
:class="props.createForm.currentPhase === phase ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="props.createForm.currentPhase = phase"
|
||||
>
|
||||
{{ phase }}
|
||||
</button>
|
||||
</fieldset>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierung startet</span>
|
||||
<input v-model="props.createForm.nominationStartsAt" type="date" 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierung endet</span>
|
||||
<input v-model="props.createForm.nominationEndsAt" type="date" 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Voting startet</span>
|
||||
<input v-model="props.createForm.votingStartsAt" type="date" 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Voting endet</span>
|
||||
<input v-model="props.createForm.votingEndsAt" type="date" 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review startet</span>
|
||||
<input v-model="props.createForm.reviewStartsAt" type="date" 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review endet</span>
|
||||
<input v-model="props.createForm.reviewEndsAt" type="date" 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" />
|
||||
</label>
|
||||
<label class="space-y-2 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Show-Datum</span>
|
||||
<input v-model="props.createForm.showDate" type="date" 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" />
|
||||
</label>
|
||||
<label class="space-y-2 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Stream startet um</span>
|
||||
<input v-model="props.createForm.showStartsAt" type="time" 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" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-5 space-y-3">
|
||||
<label class="flex cursor-pointer gap-4 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
|
||||
<input v-model="props.createForm.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
|
||||
<span class="text-sm leading-6 text-slate-600">
|
||||
Community-only aktivieren
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer gap-4 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
|
||||
<input v-model="props.createForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
|
||||
<span class="text-sm leading-6 text-slate-600">
|
||||
Dieses Jahr direkt als Public-Kontext markieren
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="props.createPublicReadinessIssues.length > 0" class="rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700" role="alert">
|
||||
<strong class="block text-rose-800">Direkte Public-Aktivierung blockiert.</strong>
|
||||
{{ props.createPublicReadinessIssues.join(' ') }}
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="props.onClose">Abbrechen</Button>
|
||||
<Button :disabled="props.creating || !props.canCreate" @click="props.onCreate">
|
||||
{{ props.creating ? 'Legt an ...' : 'Award-Jahr anlegen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertTriangle } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
interface SeasonDeleteTarget {
|
||||
id: number
|
||||
year: number
|
||||
name: string
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
seasonToDelete: SeasonDeleteTarget | null
|
||||
deleting: boolean
|
||||
onClose: () => void
|
||||
onConfirmDelete: () => Promise<void> | void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="!!props.seasonToDelete" title="Award-Jahr löschen" @close="props.onClose">
|
||||
<div v-if="props.seasonToDelete" class="space-y-4">
|
||||
<div class="flex gap-4 rounded-[24px] border border-rose-100 bg-rose-50 p-4 text-rose-800">
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold">Diese Aktion löscht das komplette Award-Jahr {{ props.seasonToDelete.year }}.</p>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
Dazu gehören Kategorien, Kandidaten, Nominierungen, Votes, Gewinner, Clips und zugehörige Risiko-Hinweise dieser Season.
|
||||
Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-[22px] border border-violet-100 bg-white/80 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Ausgewählt</p>
|
||||
<p class="mt-2 font-semibold text-slate-900">{{ props.seasonToDelete.name }}</p>
|
||||
</div>
|
||||
<p v-if="props.seasonToDelete.isCurrent" class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Dieses Jahr ist öffentlich aktiv und kann nicht gelöscht werden.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="props.onClose">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="props.deleting || !props.seasonToDelete || props.seasonToDelete.isCurrent" @click="props.onConfirmDelete">
|
||||
{{ props.deleting ? 'Löscht ...' : 'Endgültig löschen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="flex flex-col gap-4 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Phasenwechsel</p>
|
||||
<h2 class="mt-1 truncate text-xl font-bold leading-tight text-slate-900 lg:text-2xl">
|
||||
{{ props.form.name || props.seasonName || 'Kein Award-Jahr gewählt' }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
|
||||
Wechsle die öffentliche Award-Phase bewusst. Die Datumsanalyse hilft beim Abgleich, speichert aber nichts automatisch.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Aktiv</span>
|
||||
<span class="rounded-full border border-violet-200 bg-violet-100 px-4 py-2 text-sm font-bold text-violet-800">
|
||||
{{ props.form.currentPhase || 'Nicht gesetzt' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="autoMismatch" class="flex flex-col gap-3 border-b border-amber-100 bg-amber-50/70 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl bg-amber-100 text-amber-700">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-amber-900">Zeitplan empfiehlt {{ autoPhase }}</p>
|
||||
<p class="mt-1 text-sm leading-6 text-amber-800/80">
|
||||
Laut heutigen Daten liegt das Jahr in einer anderen Phase als gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
|
||||
:disabled="props.saving || !props.selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
|
||||
@click="autoPhase && !autoPhaseIsCompleted && props.activatePhase(autoPhase)"
|
||||
>
|
||||
{{ autoPhaseIsCompleted ? 'Über Beenden abschließen' : 'Automatisch aktivieren' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<article
|
||||
v-for="phase in phaseCards"
|
||||
:key="phase.key"
|
||||
class="flex min-h-[178px] flex-col rounded-[20px] border p-4 transition"
|
||||
:class="phase.cardClass"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2.5 w-2.5 rounded-full" :class="phase.dotClass"></span>
|
||||
<span class="text-xs font-bold text-slate-400">{{ phase.ordinal }}</span>
|
||||
</div>
|
||||
<span class="rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-[0.08em]" :class="phase.badgeClass">
|
||||
{{ phase.statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-bold leading-snug text-slate-900">{{ phase.title }}</h3>
|
||||
<p class="mt-1 flex-1 text-xs leading-5 text-slate-500">{{ phase.dateRange }}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-2xl px-4 py-2.5 text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="phase.active ? 'bg-violet-600 text-white' : phase.finalLocked ? 'border border-slate-200 bg-slate-50 text-slate-500' : 'border border-violet-100 bg-white text-violet-700 hover:bg-violet-50'"
|
||||
:disabled="props.saving || !props.selectedSeasonId || phase.active || phase.finalLocked"
|
||||
:title="phase.actionTitle"
|
||||
@click="props.activatePhase(phase.title)"
|
||||
>
|
||||
<CheckCircle2 v-if="phase.active" class="h-4 w-4" />
|
||||
<LockKeyhole v-else-if="phase.finalLocked" class="h-4 w-4" />
|
||||
<WandSparkles v-else class="h-4 w-4" />
|
||||
{{ phase.actionLabel }}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { AlertTriangle, CheckCircle2, LockKeyhole, WandSparkles } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import {
|
||||
createSeasonTimelineRows,
|
||||
hasShowDayPassed,
|
||||
normalizePhaseKey,
|
||||
resolveAutoPhase,
|
||||
type SeasonTimelineForm,
|
||||
} from './adminSeasonTimeline'
|
||||
|
||||
const props = defineProps<{
|
||||
form: SeasonTimelineForm & { name?: string }
|
||||
seasonName: string
|
||||
saving: boolean
|
||||
selectedSeasonId: number | null
|
||||
activatePhase: (phase: string) => Promise<void> | void
|
||||
}>()
|
||||
|
||||
const currentPhaseKey = computed(() => normalizePhaseKey(props.form.currentPhase))
|
||||
const autoPhase = computed(() => resolveAutoPhase(props.form))
|
||||
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
|
||||
const autoMismatch = computed(() => Boolean(autoPhase.value && normalizePhaseKey(autoPhase.value) !== currentPhaseKey.value))
|
||||
const phaseCards = computed(() =>
|
||||
createSeasonTimelineRows(props.form, currentPhaseKey.value, autoPhase.value, null).map((phase, index) => {
|
||||
const isAutoActive = autoPhase.value === phase.title && !phase.active
|
||||
const isDone = phase.statusLabel === 'Erledigt'
|
||||
const isCompletedPhase = phase.key === 'completed'
|
||||
const finalLocked = isCompletedPhase && !phase.active
|
||||
const showHasPassed = hasShowDayPassed(props.form)
|
||||
|
||||
return {
|
||||
...phase,
|
||||
ordinal: String(index + 1).padStart(2, '0'),
|
||||
finalLocked,
|
||||
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Aktivieren',
|
||||
actionTitle: isCompletedPhase
|
||||
? showHasPassed
|
||||
? 'Abschluss erfolgt über den Beenden-Flow in den Grunddaten.'
|
||||
: 'Abschluss ist erst nach der Award Show möglich.'
|
||||
: `Phase ${phase.title} aktivieren`,
|
||||
cardClass: phase.active
|
||||
? 'border-violet-200 bg-violet-50 shadow-[0_18px_42px_rgba(139,108,219,0.14)]'
|
||||
: isAutoActive
|
||||
? 'border-amber-200 bg-amber-50/70'
|
||||
: 'border-violet-100 bg-white/90 hover:border-violet-200 hover:bg-violet-50/40',
|
||||
dotClass: phase.active ? 'bg-violet-600' : isAutoActive ? 'bg-amber-500' : isDone ? 'bg-emerald-500' : 'bg-slate-300',
|
||||
badgeClass: phase.active
|
||||
? 'border-violet-200 bg-violet-100 text-violet-800'
|
||||
: isAutoActive
|
||||
? 'border-amber-200 bg-white text-amber-700'
|
||||
: isDone
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
}
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<Card class="flex h-[684px] flex-col overflow-hidden">
|
||||
<div class="shrink-0 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Grunddaten</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ seasonName || 'Kein Jahr gewählt' }}</h2>
|
||||
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
|
||||
Die wichtigsten Stammdaten, der Public-Kontext und der finale Stream-Link auf einen Blick.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="form.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
|
||||
{{ form.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 min-[1400px]:grid-cols-[minmax(0,1fr)_minmax(300px,0.78fr)]">
|
||||
<div class="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" 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:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" placeholder="2027" :disabled="!selectedSeasonId || saving" />
|
||||
</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" 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:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" placeholder="VTuber Star Awards 2027" :disabled="!selectedSeasonId || saving" />
|
||||
</label>
|
||||
<label class="block space-y-2 sm:col-span-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" 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:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" placeholder="https://twitch.tv/jayuhime" :disabled="!selectedSeasonId || saving" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="flex min-h-[108px] cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:cursor-not-allowed has-disabled:opacity-70">
|
||||
<input v-model="form.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="!selectedSeasonId || 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 laufen über die Community.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex min-h-[108px] cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:cursor-not-allowed has-disabled:opacity-70">
|
||||
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="!selectedSeasonId || 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">
|
||||
{{ canActivatePublic || form.isCurrent ? 'Nur ein Jahr sollte öffentlich sichtbar sein.' : 'Erst die Readiness-Blocker unten loesen.' }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" role="status">{{ 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" role="alert">{{ adminError }}</p>
|
||||
|
||||
<div class="grid gap-3 border-t border-violet-100 pt-4 md:grid-cols-3">
|
||||
<Button variant="ghost" class="w-full gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="!selectedSeasonId || !canDeleteSelectedSeason" @click="openDeleteSeasonModal">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Löschen
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-full gap-2 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="completing || !selectedSeasonId || !canCompleteSelectedSeason" @click="completeSeason">
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
{{ completing ? 'Schliesst ...' : 'Beenden' }}
|
||||
</Button>
|
||||
<Button class="w-full" :disabled="saving || !selectedSeasonId" @click="saveSeason">
|
||||
{{ saving ? 'Speichert ...' : 'Speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="selectedSeasonIsCurrent" class="text-xs leading-5 text-slate-500">
|
||||
Das öffentliche aktive Jahr kann nicht gelöscht werden. Schalte zuerst ein anderes Jahr öffentlich.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside class="min-h-0 space-y-4 lg:max-h-[432px] lg:overflow-y-auto lg:pr-1">
|
||||
<div class="rounded-[22px] border border-violet-100 bg-white p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div 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" />
|
||||
</div>
|
||||
<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 ? 'Laedt Audit ...' : latestSeasonAuditSummary }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">{{ latestSeasonAuditMeta }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[22px] border border-violet-100 bg-white p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Public-Readiness</p>
|
||||
<p class="mt-1 text-sm leading-5 text-slate-500">Blocker für Public auf einen Blick.</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full border px-3 py-1 text-xs font-semibold" :class="canActivatePublic ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700'">
|
||||
{{ canActivatePublic ? 'Bereit' : 'Blockiert' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-2">
|
||||
<RouterLink
|
||||
v-for="item in readinessItems"
|
||||
:key="item.label"
|
||||
:to="item.to"
|
||||
class="flex min-h-[58px] items-start gap-3 rounded-2xl border px-3 py-2.5 transition hover:bg-violet-50/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
:class="item.complete ? 'border-emerald-100 bg-emerald-50/60' : item.blocking ? 'border-rose-200 bg-rose-50/70' : 'border-amber-100 bg-amber-50/60'"
|
||||
>
|
||||
<CheckCircle2 v-if="item.complete" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-700" />
|
||||
<AlertTriangle v-else class="mt-0.5 h-4 w-4 shrink-0" :class="item.blocking ? 'text-rose-700' : 'text-amber-700'" />
|
||||
<span class="min-w-0">
|
||||
<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-[11px] text-rose-700">Blocker</span>
|
||||
</span>
|
||||
<span class="mt-0.5 block text-xs leading-5 text-slate-500">{{ item.note }}</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="archiveReadinessIssues.length > 0" class="rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700" role="alert">
|
||||
<strong class="block text-rose-800">Abschluss noch blockiert.</strong>
|
||||
{{ archiveReadinessIssues.join(' ') }}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AlertTriangle, CheckCircle2, History, Trash2 } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import type { AdminSeasonForm, AdminSeasonReadinessItem } from './adminSeasonTypes'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
form: AdminSeasonForm
|
||||
seasonName: string
|
||||
saving: boolean
|
||||
completing: boolean
|
||||
adminMessage: string
|
||||
adminError: string
|
||||
readinessItems: AdminSeasonReadinessItem[]
|
||||
archiveReadinessIssues: string[]
|
||||
canActivatePublic: boolean
|
||||
loadingSeasonAudit: boolean
|
||||
latestSeasonAuditSummary: string
|
||||
latestSeasonAuditMeta: string
|
||||
selectedSeasonId: number | null
|
||||
canDeleteSelectedSeason: boolean
|
||||
canCompleteSelectedSeason: boolean
|
||||
selectedSeasonIsCurrent: boolean
|
||||
openDeleteSeasonModal: () => void
|
||||
saveSeason: () => Promise<boolean | void> | boolean | void
|
||||
completeSeason: () => Promise<void>
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<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">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ props.form.year || '-' }} · Timeline</p>
|
||||
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-900">Phasen bearbeiten</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
|
||||
Pflege die echten Zeitfenster für Landingpage, Public-API und Teilnahme-Gates. Änderungen werden direkt im gewählten Award-Jahr gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
<span class="w-fit rounded-full border border-violet-200 bg-white px-4 py-2 text-sm font-bold text-violet-700">
|
||||
{{ props.form.currentPhase || 'Keine Phase gesetzt' }}
|
||||
</span>
|
||||
</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" role="alert">{{ timelineError }}</p>
|
||||
|
||||
<div class="hidden grid-cols-[34px_minmax(0,1fr)_240px_128px_110px] gap-4 border-b border-violet-100 bg-violet-50/50 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500 lg:grid">
|
||||
<span></span>
|
||||
<span>Phase</span>
|
||||
<span>Zeitraum</span>
|
||||
<span>Status</span>
|
||||
<span class="text-right">Bearbeiten</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[390px] overflow-y-auto divide-y divide-violet-50">
|
||||
<div
|
||||
v-for="row in timelineRows"
|
||||
:key="row.key"
|
||||
class="grid gap-4 px-5 py-3.5 lg:grid-cols-[34px_minmax(0,1fr)_240px_128px_110px] lg:items-center"
|
||||
:class="row.isEditing ? 'bg-violet-50/50' : 'bg-white/70'"
|
||||
>
|
||||
<span class="mt-2 h-2.5 w-2.5 rounded-full lg:mx-auto lg:mt-0" :class="row.dotClass"></span>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-slate-900">{{ row.title }}</p>
|
||||
<p class="mt-1 text-sm leading-5 text-slate-500">{{ row.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="row.isEditing" class="grid gap-2">
|
||||
<template v-if="row.key === 'show'">
|
||||
<input v-model="draft.start" type="date" class="h-10 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="draft.showStartsAt" type="time" class="h-10 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
||||
<input v-model="draft.start" type="date" class="h-10 min-w-0 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="draft.end" type="date" class="h-10 min-w-0 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p v-else class="text-sm font-semibold text-slate-600">{{ row.dateRange }}</p>
|
||||
|
||||
<span class="w-fit rounded-full border px-3 py-1 text-xs font-bold" :class="row.statusClass">
|
||||
{{ row.statusLabel }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap justify-start gap-2 lg:justify-end">
|
||||
<template v-if="row.isEditing">
|
||||
<Button size="sm" :disabled="props.saving" @click="saveTimelineEdit(row)">OK</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-9 w-9 place-items-center rounded-xl border border-violet-100 bg-white text-slate-500 transition hover:bg-violet-50"
|
||||
:disabled="props.saving"
|
||||
title="Bearbeitung abbrechen"
|
||||
@click="cancelTimelineEdit"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="row.editable"
|
||||
type="button"
|
||||
class="grid h-9 w-9 place-items-center rounded-xl border border-violet-100 bg-white text-violet-700 transition hover:bg-violet-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="props.saving || !props.selectedSeasonId"
|
||||
title="Zeitraum bearbeiten"
|
||||
@click="startTimelineEdit(row)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</button>
|
||||
<span v-else class="text-xs font-semibold text-slate-400">fest</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-violet-100 bg-violet-50/30 px-5 py-3">
|
||||
<p class="text-xs leading-5 text-slate-500">
|
||||
Hinweis: Die Phasen sind als Systemphasen fest verdrahtet, damit Nominierung, Voting, Review und Show sicher mit Public-API und Rate-Limits zusammenspielen.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { Pencil, X } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import {
|
||||
createSeasonTimelineRows,
|
||||
normalizePhaseKey,
|
||||
resolveAutoPhase,
|
||||
type PhaseKey,
|
||||
type PhaseRowConfig,
|
||||
type SeasonTimelineForm,
|
||||
} from './adminSeasonTimeline'
|
||||
|
||||
const props = defineProps<{
|
||||
form: SeasonTimelineForm
|
||||
saving: boolean
|
||||
selectedSeasonId: number | null
|
||||
saveSeason: () => Promise<boolean | void> | boolean | void
|
||||
}>()
|
||||
|
||||
const editingPhase = ref<PhaseKey | null>(null)
|
||||
const timelineError = ref('')
|
||||
const draft = reactive({
|
||||
start: '',
|
||||
end: '',
|
||||
showStartsAt: '20:00',
|
||||
})
|
||||
|
||||
const currentPhaseKey = computed(() => normalizePhaseKey(props.form.currentPhase))
|
||||
const autoPhase = computed(() => resolveAutoPhase(props.form))
|
||||
const timelineRows = computed(() =>
|
||||
createSeasonTimelineRows(props.form, currentPhaseKey.value, autoPhase.value, editingPhase.value),
|
||||
)
|
||||
|
||||
function startTimelineEdit(row: PhaseRowConfig) {
|
||||
if (!row.editable) return
|
||||
timelineError.value = ''
|
||||
editingPhase.value = row.key
|
||||
draft.start = row.start ? props.form[row.start] : ''
|
||||
draft.end = row.end ? props.form[row.end] : ''
|
||||
draft.showStartsAt = props.form.showStartsAt || '20:00'
|
||||
}
|
||||
|
||||
function cancelTimelineEdit() {
|
||||
editingPhase.value = null
|
||||
timelineError.value = ''
|
||||
}
|
||||
|
||||
async function saveTimelineEdit(row: PhaseRowConfig) {
|
||||
timelineError.value = ''
|
||||
if (!row.start || !row.end) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!draft.start || !draft.end) {
|
||||
timelineError.value = 'Bitte fülle Start und Ende der Phase aus.'
|
||||
return
|
||||
}
|
||||
|
||||
if (row.key !== 'show' && draft.start > draft.end) {
|
||||
timelineError.value = 'Das Startdatum darf nicht nach dem Enddatum liegen.'
|
||||
return
|
||||
}
|
||||
|
||||
props.form[row.start] = draft.start
|
||||
props.form[row.end] = row.key === 'show' ? draft.start : draft.end
|
||||
if (row.key === 'show') {
|
||||
props.form.showStartsAt = draft.showStartsAt || '20:00'
|
||||
}
|
||||
|
||||
const saved = await props.saveSeason()
|
||||
if (saved !== false) {
|
||||
editingPhase.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Select from 'primevue/select'
|
||||
import { CalendarCog, CheckCircle2, Clock3, Sparkles, Tags, Users } from '@lucide/vue'
|
||||
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
@@ -61,12 +61,9 @@ const yearStats = computed(() => [
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Jahr wechseln</label>
|
||||
<Select
|
||||
<NativeSelect
|
||||
v-model="selectedSeasonId"
|
||||
:options="seasonOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertTriangle, CheckCircle2, Database, Loader2, RefreshCw } from '@lucide/vue'
|
||||
|
||||
import type { DatabaseHealthResponse } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
healthLoading: boolean
|
||||
healthError: string
|
||||
databaseHealth: DatabaseHealthResponse
|
||||
pendingMigrationCount: number
|
||||
healthLoadedLabel: string
|
||||
refreshDatabaseHealth: () => Promise<void> | void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl" :class="databaseHealth.canConnect ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||
<Database class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Live-Systemstatus</p>
|
||||
<h2 class="mt-1 text-lg font-bold text-slate-900">Datenbank-Healthcheck</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Zuletzt geprüft: {{ healthLoadedLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" :disabled="healthLoading" @click="refreshDatabaseHealth">
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': healthLoading }" />
|
||||
{{ healthLoading ? 'Prüft ...' : 'Aktualisieren' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="healthError" class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ healthError }}</p>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-violet-100 bg-white/80 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Provider</p>
|
||||
<strong class="mt-1 block text-sm text-slate-900">{{ databaseHealth.provider }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white/80 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Connection</p>
|
||||
<strong class="mt-1 block text-sm text-slate-900">{{ databaseHealth.configuredConnection.source }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border px-4 py-3" :class="pendingMigrationCount > 0 ? 'border-amber-100 bg-amber-50/70' : 'border-emerald-100 bg-emerald-50/60'">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Migrationen</p>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<Loader2 v-if="healthLoading" class="h-4 w-4 animate-spin text-violet-600" />
|
||||
<AlertTriangle v-else-if="pendingMigrationCount > 0" class="h-4 w-4 text-amber-700" />
|
||||
<CheckCircle2 v-else class="h-4 w-4 text-emerald-700" />
|
||||
<strong class="block text-sm text-slate-900">{{ pendingMigrationCount > 0 ? `${pendingMigrationCount} offen` : 'Aktuell' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingMigrationCount > 0" class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<p class="font-semibold">Ausstehende Migrationen</p>
|
||||
<p class="mt-1">{{ databaseHealth.pendingMigrations.join(', ') }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { CheckCircle2, CircleDashed, ExternalLink } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import type { AdminSettingsCheckItem, AdminSettingsGateItem, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
checks: AdminSettingsCheckItem[]
|
||||
gates: AdminSettingsGateItem[]
|
||||
contentChecks: AdminSettingsCheckItem[]
|
||||
contentCompletion: number
|
||||
}>()
|
||||
|
||||
const readyChecks = computed(() => props.checks.filter((check) => check.value).length)
|
||||
const activeGates = computed(() => props.gates.filter((gate) => gate.state).length)
|
||||
const incompleteContentCount = computed(() => props.contentChecks.length - props.contentCompletion)
|
||||
|
||||
const summaryItems = computed<AdminSettingsStatusSummary[]>(() => [
|
||||
{
|
||||
label: 'Systemchecks',
|
||||
value: `${readyChecks.value}/${props.checks.length}`,
|
||||
note: readyChecks.value === props.checks.length ? 'Alles grün.' : 'Mindestens ein Check braucht Aufmerksamkeit.',
|
||||
tone: readyChecks.value === props.checks.length ? 'good' : 'warning',
|
||||
},
|
||||
{
|
||||
label: 'Betriebszustände',
|
||||
value: `${activeGates.value}/${props.gates.length}`,
|
||||
note: 'Aktive Phasen und offene Moderationspunkte.',
|
||||
tone: activeGates.value > 0 ? 'neutral' : 'warning',
|
||||
},
|
||||
{
|
||||
label: 'Landingpage',
|
||||
value: `${props.contentCompletion}/${props.contentChecks.length}`,
|
||||
note: incompleteContentCount.value === 0 ? 'Public-Inhalte vollständig.' : `${incompleteContentCount.value} Bereiche fehlen.`,
|
||||
tone: incompleteContentCount.value === 0 ? 'good' : 'warning',
|
||||
},
|
||||
])
|
||||
|
||||
function toneClasses(tone: AdminSettingsTone) {
|
||||
if (tone === 'good') {
|
||||
return {
|
||||
panel: 'border-emerald-100 bg-emerald-50/55',
|
||||
text: 'text-emerald-700',
|
||||
icon: 'bg-emerald-100 text-emerald-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (tone === 'warning') {
|
||||
return {
|
||||
panel: 'border-amber-100 bg-amber-50/65',
|
||||
text: 'text-amber-700',
|
||||
icon: 'bg-amber-100 text-amber-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (tone === 'danger') {
|
||||
return {
|
||||
panel: 'border-rose-100 bg-rose-50/65',
|
||||
text: 'text-rose-700',
|
||||
icon: 'bg-rose-100 text-rose-700',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
panel: 'border-violet-100 bg-violet-50/55',
|
||||
text: 'text-violet-700',
|
||||
icon: 'bg-violet-100 text-violet-700',
|
||||
}
|
||||
}
|
||||
|
||||
function booleanTone(value: boolean): AdminSettingsTone {
|
||||
return value ? 'good' : 'warning'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/50 p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Betriebsübersicht</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Alles Wichtige auf einen Blick</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Systemchecks, aktive Phasen und Landingpage-Readiness stehen jetzt zusammen, ohne dieselben Inhalte noch einmal editierbar zu duplizieren.
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink to="/admin/content" class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl bg-violet-600 px-5 text-sm font-semibold !text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500" style="color:#fff;">
|
||||
<ExternalLink class="h-4 w-4 !text-white" />
|
||||
Content Hub
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 p-5 md:grid-cols-3">
|
||||
<div
|
||||
v-for="item in summaryItems"
|
||||
:key="item.label"
|
||||
class="rounded-2xl border px-4 py-3"
|
||||
:class="toneClasses(item.tone).panel"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</p>
|
||||
<strong class="mt-1 block text-2xl" :class="toneClasses(item.tone).text">{{ item.value }}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-600">{{ item.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid border-t border-violet-100 lg:grid-cols-3">
|
||||
<section class="border-b border-violet-100 p-5 lg:border-b-0 lg:border-r">
|
||||
<h3 class="text-sm font-bold text-slate-900">Systemchecks</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<component
|
||||
:is="check.to ? RouterLink : 'div'"
|
||||
v-for="check in checks"
|
||||
:key="check.label"
|
||||
:to="check.to ?? undefined"
|
||||
class="flex items-start gap-3 rounded-2xl border px-4 py-3 transition"
|
||||
:class="[toneClasses(booleanTone(check.value)).panel, check.to ? 'hover:bg-violet-50/60' : '']"
|
||||
>
|
||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="toneClasses(booleanTone(check.value)).icon">
|
||||
<component :is="check.icon" class="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ check.label }}</p>
|
||||
<p class="mt-0.5 text-xs leading-5 text-slate-500">{{ check.note }}</p>
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-b border-violet-100 p-5 lg:border-b-0 lg:border-r">
|
||||
<h3 class="text-sm font-bold text-slate-900">Betriebszustände</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<RouterLink
|
||||
v-for="gate in gates"
|
||||
:key="gate.label"
|
||||
:to="gate.to"
|
||||
class="flex items-start gap-3 rounded-2xl border px-4 py-3 transition hover:bg-violet-50/60"
|
||||
:class="toneClasses(gate.state ? 'good' : 'neutral').panel"
|
||||
>
|
||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="toneClasses(gate.state ? 'good' : 'neutral').icon">
|
||||
<CheckCircle2 v-if="gate.state" class="h-4.5 w-4.5" />
|
||||
<CircleDashed v-else class="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ gate.label }}</p>
|
||||
<p class="mt-0.5 text-xs leading-5 text-slate-500">{{ gate.note }}</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="p-5">
|
||||
<h3 class="text-sm font-bold text-slate-900">Landingpage-Readiness</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<RouterLink
|
||||
v-for="check in contentChecks"
|
||||
:key="check.label"
|
||||
to="/admin/content"
|
||||
class="flex items-start gap-3 rounded-2xl border px-4 py-3 transition hover:bg-violet-50/60"
|
||||
:class="toneClasses(booleanTone(check.value)).panel"
|
||||
>
|
||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="toneClasses(booleanTone(check.value)).icon">
|
||||
<component :is="check.icon" class="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ check.label }}</p>
|
||||
<p class="mt-0.5 text-xs leading-5 text-slate-500">{{ check.note }}</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
label: string
|
||||
activeLabel?: string
|
||||
inactiveLabel?: string
|
||||
disabled?: boolean
|
||||
tone?: 'violet' | 'amber'
|
||||
}>(), {
|
||||
activeLabel: 'Aktiv',
|
||||
inactiveLabel: 'Inaktiv',
|
||||
disabled: false,
|
||||
tone: 'violet',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
function toggle() {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
class="inline-flex items-center gap-3 rounded-2xl border px-4 py-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="tone === 'amber' && modelValue ? 'border-amber-200 bg-white/75 text-amber-800' : 'border-violet-100 bg-violet-50/60 text-violet-800'"
|
||||
@click="toggle"
|
||||
>
|
||||
<span>{{ modelValue ? activeLabel : inactiveLabel }}</span>
|
||||
<span
|
||||
class="relative h-7 w-12 rounded-full transition"
|
||||
:class="modelValue ? tone === 'amber' ? 'bg-amber-500' : 'bg-violet-600' : 'bg-slate-200'"
|
||||
>
|
||||
<span class="absolute left-1 top-1 h-5 w-5 rounded-full bg-white shadow transition" :class="{ 'translate-x-5': modelValue }"></span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
export type SocialLinkForm = {
|
||||
label: string
|
||||
platform: string
|
||||
icon: string
|
||||
url: string
|
||||
showOnHost: boolean
|
||||
showOnCommunity: boolean
|
||||
}
|
||||
|
||||
export type FaqFormItem = {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export type AdminContentForm = {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
newsletterUrl: string
|
||||
privacyEmail: string
|
||||
privacyPolicyContent: string
|
||||
imprintUrl: string
|
||||
contactUrl: string
|
||||
sponsorsUrl: string
|
||||
socialLinks: SocialLinkForm[]
|
||||
faq: FaqFormItem[]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
export interface ReviewFormState {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
reviewNote: string
|
||||
}
|
||||
|
||||
export interface ReviewStatItem {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface ReviewCategoryOption {
|
||||
id: number
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ReviewNominationCollision extends AdminCandidateItem {}
|
||||
|
||||
export interface ReviewManagerState {
|
||||
reviewSaving: number | null
|
||||
adminMessage: string
|
||||
adminError: string
|
||||
reviewFilter: string
|
||||
categoryFilter: number | null
|
||||
selectedNominationId: number | null
|
||||
reviewForms: Record<number, ReviewFormState>
|
||||
filteredNominations: AdminNominationReviewItem[]
|
||||
selectedNomination: AdminNominationReviewItem | null
|
||||
reviewStats: ReviewStatItem[]
|
||||
reviewedNominations: AdminNominationReviewItem[]
|
||||
categoryOptions: ReviewCategoryOption[]
|
||||
selectedCandidateCollision: ReviewNominationCollision | null
|
||||
canApproveSelected: boolean
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
export type PhaseKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
|
||||
|
||||
export type SeasonDateField =
|
||||
| 'nominationStartsAt'
|
||||
| 'nominationEndsAt'
|
||||
| 'votingStartsAt'
|
||||
| 'votingEndsAt'
|
||||
| 'reviewStartsAt'
|
||||
| 'reviewEndsAt'
|
||||
| 'showDate'
|
||||
|
||||
export interface SeasonTimelineForm {
|
||||
year: number
|
||||
currentPhase: string
|
||||
nominationStartsAt: string
|
||||
nominationEndsAt: string
|
||||
votingStartsAt: string
|
||||
votingEndsAt: string
|
||||
reviewStartsAt: string
|
||||
reviewEndsAt: string
|
||||
showDate: string
|
||||
showStartsAt: string
|
||||
}
|
||||
|
||||
export interface PhaseRowConfig {
|
||||
key: PhaseKey
|
||||
title: string
|
||||
description: string
|
||||
start: SeasonDateField | null
|
||||
end: SeasonDateField | null
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export interface SeasonTimelineRow extends PhaseRowConfig {
|
||||
active: boolean
|
||||
isEditing: boolean
|
||||
dateRange: string
|
||||
statusLabel: string
|
||||
dotClass: string
|
||||
statusClass: string
|
||||
}
|
||||
|
||||
export const SEASON_PHASES: PhaseRowConfig[] = [
|
||||
{
|
||||
key: 'nomination',
|
||||
title: 'Nominierung',
|
||||
description: 'Community schlägt Creator und Clips vor.',
|
||||
start: 'nominationStartsAt',
|
||||
end: 'nominationEndsAt',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
key: 'voting',
|
||||
title: 'Community Voting',
|
||||
description: 'Finale Kandidat:innen sind sichtbar und wählbar.',
|
||||
start: 'votingStartsAt',
|
||||
end: 'votingEndsAt',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
title: 'Review & Auswertung',
|
||||
description: 'Team prüft Votes, Clips und Ergebnisse.',
|
||||
start: 'reviewStartsAt',
|
||||
end: 'reviewEndsAt',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
key: 'show',
|
||||
title: 'Award Show',
|
||||
description: 'Finale Live-Show inklusive Stream-Countdown.',
|
||||
start: 'showDate',
|
||||
end: 'showDate',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
title: 'Abgeschlossen',
|
||||
description: 'Award-Jahr ist archiviert und Public-Aktionen sind gesperrt.',
|
||||
start: null,
|
||||
end: null,
|
||||
editable: false,
|
||||
},
|
||||
]
|
||||
|
||||
export function createSeasonTimelineRows(
|
||||
form: SeasonTimelineForm,
|
||||
currentPhaseKey: PhaseKey | string,
|
||||
autoPhase: string,
|
||||
editingPhase: PhaseKey | null,
|
||||
): SeasonTimelineRow[] {
|
||||
return SEASON_PHASES.map((phase) => {
|
||||
const active = phase.key === currentPhaseKey
|
||||
const isAutoActive = autoPhase === phase.title
|
||||
const state = active ? 'active' : resolvePhaseState(form, phase)
|
||||
|
||||
return {
|
||||
...phase,
|
||||
active,
|
||||
isEditing: editingPhase === phase.key,
|
||||
dateRange: formatPhaseRange(form, phase),
|
||||
statusLabel: active ? 'Aktiv' : isAutoActive ? 'Auto' : state === 'done' ? 'Erledigt' : 'Geplant',
|
||||
dotClass: active ? 'bg-violet-600' : isAutoActive ? 'bg-amber-500' : state === 'done' ? 'bg-emerald-500' : 'bg-slate-300',
|
||||
statusClass: active
|
||||
? 'border-violet-200 bg-violet-100 text-violet-800'
|
||||
: isAutoActive
|
||||
? 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
: state === 'done'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizePhaseKey(value: string): PhaseKey | string {
|
||||
const phase = value.trim().toLowerCase()
|
||||
if (phase.includes('abgeschlossen') || phase.includes('archiv') || phase.includes('complete') || phase.includes('ended')) return 'completed'
|
||||
if (phase.includes('show')) return 'show'
|
||||
if (phase.includes('review') || phase.includes('auswert')) return 'review'
|
||||
if (phase.includes('vot')) return 'voting'
|
||||
if (phase.includes('nomin')) return 'nomination'
|
||||
return phase
|
||||
}
|
||||
|
||||
export function resolveAutoPhase(form: SeasonTimelineForm) {
|
||||
const today = startOfDay(new Date())
|
||||
for (const phase of SEASON_PHASES.slice(0, 4)) {
|
||||
const start = phase.start ? parseDate(form[phase.start]) : null
|
||||
const end = phase.end ? parseDate(form[phase.end]) : null
|
||||
if (start && end && start <= today && today <= end) {
|
||||
return phase.title
|
||||
}
|
||||
}
|
||||
|
||||
return hasShowDayPassed(form) ? 'Abgeschlossen' : ''
|
||||
}
|
||||
|
||||
function resolvePhaseState(form: SeasonTimelineForm, phase: PhaseRowConfig) {
|
||||
const today = startOfDay(new Date())
|
||||
if (phase.key === 'completed') {
|
||||
return hasShowDayPassed(form) ? 'done' : 'upcoming'
|
||||
}
|
||||
|
||||
const end = phase.end ? parseDate(form[phase.end]) : null
|
||||
return end && end < today ? 'done' : 'upcoming'
|
||||
}
|
||||
|
||||
function formatPhaseRange(form: SeasonTimelineForm, phase: PhaseRowConfig) {
|
||||
if (phase.key === 'completed') {
|
||||
return form.showDate ? `nach ${formatDate(form.showDate)}` : 'nach der Show'
|
||||
}
|
||||
|
||||
const start = phase.start ? form[phase.start] : ''
|
||||
const end = phase.end ? form[phase.end] : ''
|
||||
if (!start && !end) return 'Noch nicht terminiert'
|
||||
if (phase.key === 'show') return `${formatDate(start)} · ${form.showStartsAt || '20:00'} Uhr`
|
||||
if (start === end) return formatDate(start)
|
||||
return `${formatDate(start)} - ${formatDate(end)}`
|
||||
}
|
||||
|
||||
function parseDate(value: string) {
|
||||
if (!value) return null
|
||||
const date = startOfDay(new Date(`${value}T00:00:00`))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function hasShowDayPassed(form: Pick<SeasonTimelineForm, 'showDate'>) {
|
||||
const showDate = parseDate(form.showDate)
|
||||
return Boolean(showDate && startOfDay(new Date()) > showDate)
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
const next = new Date(date)
|
||||
next.setHours(0, 0, 0, 0)
|
||||
return next
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
if (!value) return 'offen'
|
||||
const date = parseDate(value)
|
||||
return date
|
||||
? date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: value
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface AdminSeasonForm {
|
||||
year: number
|
||||
name: string
|
||||
showStreamUrl: string
|
||||
currentPhase: string
|
||||
isCurrent: boolean
|
||||
isCommunityOnly: boolean
|
||||
nominationStartsAt: string
|
||||
nominationEndsAt: string
|
||||
votingStartsAt: string
|
||||
votingEndsAt: string
|
||||
reviewStartsAt: string
|
||||
reviewEndsAt: string
|
||||
showDate: string
|
||||
showStartsAt: string
|
||||
}
|
||||
|
||||
export interface AdminSeasonCreateForm extends AdminSeasonForm {
|
||||
copyStructureFromSeasonId: number | null
|
||||
}
|
||||
|
||||
export interface AdminSeasonReadinessItem {
|
||||
label: string
|
||||
note: string
|
||||
complete: boolean
|
||||
blocking: boolean
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface AdminSeasonCopyOption {
|
||||
label: string
|
||||
value: number | null
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type AdminSettingsTone = 'good' | 'warning' | 'danger' | 'neutral'
|
||||
|
||||
export interface AdminOperationalSettingsForm {
|
||||
demoLoginEnabled: boolean
|
||||
demoLoginIdentifier: string
|
||||
demoLoginTwitchUserId: string
|
||||
demoLoginDisplayName: string
|
||||
maintenanceModeEnabled: boolean
|
||||
maintenanceTitle: string
|
||||
maintenanceMessage: string
|
||||
}
|
||||
|
||||
export interface AdminSettingsCheckItem {
|
||||
label: string
|
||||
value: boolean
|
||||
note: string
|
||||
icon: Component
|
||||
to: string | null
|
||||
}
|
||||
|
||||
export interface AdminSettingsGateItem {
|
||||
label: string
|
||||
state: boolean
|
||||
note: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface AdminSettingsStatusSummary {
|
||||
label: string
|
||||
value: string
|
||||
note: string
|
||||
tone: AdminSettingsTone
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { computed } from 'vue'
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Sparkles, Tags, Trophy, Users, Vote } from '@lucide/vue'
|
||||
|
||||
import { getVoteMetricValue } from '../../lib/adminMetrics'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
export function useAdminAnalyticsManager() {
|
||||
const store = useAwardsStore()
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const topCategories = computed(() => store.admin.topCategories)
|
||||
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
|
||||
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
|
||||
const maxVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
|
||||
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
|
||||
const voteMap = computed(() => new Map(topCategories.value.map((category) => [category.category, category.votes])))
|
||||
|
||||
const categoryHealth = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => {
|
||||
const candidates = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length
|
||||
const reviews = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
|
||||
const hasWinner = resultMap.value.has(category.id)
|
||||
const votes = voteMap.value.get(category.name) ?? 0
|
||||
const status = candidates === 0 ? 'Leer' : reviews > 0 ? 'Review offen' : hasWinner ? 'Gewinner gesetzt' : 'Bereit'
|
||||
|
||||
return {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
groupName: category.groupName,
|
||||
candidates,
|
||||
reviews,
|
||||
hasWinner,
|
||||
votes,
|
||||
status,
|
||||
statusClass: candidates === 0
|
||||
? 'border-rose-100 bg-rose-50 text-rose-700'
|
||||
: reviews > 0
|
||||
? 'border-amber-100 bg-amber-50 text-amber-700'
|
||||
: hasWinner
|
||||
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
: 'border-sky-100 bg-sky-50 text-sky-700',
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.reviews - a.reviews || a.candidates - b.candidates || b.votes - a.votes),
|
||||
)
|
||||
|
||||
const emptyCategories = computed(() => categoryHealth.value.filter((category) => category.candidates === 0))
|
||||
const categoriesWithReviews = computed(() => categoryHealth.value.filter((category) => category.reviews > 0))
|
||||
const categoriesWithoutWinner = computed(() => categoryHealth.value.filter((category) => !category.hasWinner))
|
||||
const pendingClipCount = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
|
||||
const winnerCoveragePct = computed(() => {
|
||||
const categoryCount = seasonDetail.value.categories.length
|
||||
if (categoryCount === 0) return 0
|
||||
return Math.round((seasonDetail.value.results.length / categoryCount) * 100)
|
||||
})
|
||||
|
||||
const metricCards = computed(() => [
|
||||
{ label: 'Nominierungen', value: totalNominations.value, note: 'eingereicht im Jahr', icon: Sparkles, tone: 'text-fuchsia-700 bg-fuchsia-50 border-fuchsia-100' },
|
||||
{ label: 'Stimmen', value: totalVotes.value, note: 'gezählte Votes', icon: Vote, tone: 'text-violet-700 bg-violet-50 border-violet-100' },
|
||||
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'freigegeben in Kategorien', icon: Users, tone: 'text-cyan-700 bg-cyan-50 border-cyan-100' },
|
||||
{ label: 'Review-Backlog', value: seasonDetail.value.pendingNominations.length, note: 'offene Entscheidungen', icon: Clock3, tone: 'text-amber-700 bg-amber-50 border-amber-100' },
|
||||
])
|
||||
|
||||
const readinessCards = computed(() => [
|
||||
{
|
||||
label: 'Kategorie-Abdeckung',
|
||||
value: `${seasonDetail.value.categories.length - emptyCategories.value.length}/${seasonDetail.value.categories.length}`,
|
||||
note: emptyCategories.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${emptyCategories.value.length} Kategorien sind noch leer.`,
|
||||
icon: Tags,
|
||||
to: emptyCategories.value.length > 0 ? '/admin/candidates' : '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Gewinnerstatus',
|
||||
value: `${winnerCoveragePct.value}%`,
|
||||
note: `${seasonDetail.value.results.length} von ${seasonDetail.value.categories.length} Kategorien final gesetzt.`,
|
||||
icon: Trophy,
|
||||
to: '/admin/winners',
|
||||
},
|
||||
{
|
||||
label: 'Freigabe-Risiko',
|
||||
value: String(categoriesWithReviews.value.length + pendingClipCount.value),
|
||||
note: `${categoriesWithReviews.value.length} Kategorien mit Reviews, ${pendingClipCount.value} Clips offen.`,
|
||||
icon: AlertTriangle,
|
||||
to: categoriesWithReviews.value.length > 0 ? '/admin/nominations?review=1' : '/admin/clips',
|
||||
},
|
||||
])
|
||||
|
||||
const insightCards = computed(() => {
|
||||
const votesPerCandidate = seasonDetail.value.candidates.length === 0 ? 0 : Math.round(totalVotes.value / seasonDetail.value.candidates.length)
|
||||
const busiestReviewCategory = categoriesWithReviews.value[0]
|
||||
const strongestCategory = topCategories.value[0]
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Votes pro Kandidat',
|
||||
value: votesPerCandidate.toLocaleString('de-DE'),
|
||||
note: 'Zeigt, ob die Kandidatenbasis breit genug fuer die aktuelle Vote-Menge ist.',
|
||||
icon: Vote,
|
||||
},
|
||||
{
|
||||
label: 'Staerkste Kategorie',
|
||||
value: strongestCategory?.votes.toLocaleString('de-DE') ?? '0',
|
||||
note: strongestCategory ? strongestCategory.category : 'Noch keine Vote-Verteilung vorhanden.',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
label: 'Review-Hotspot',
|
||||
value: String(busiestReviewCategory?.reviews ?? 0),
|
||||
note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine offenen Review-Hotspots.',
|
||||
icon: Clock3,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const attentionItems = computed(() => [
|
||||
{
|
||||
key: 'empty-categories',
|
||||
label: 'Leere Kategorien',
|
||||
value: emptyCategories.value.length,
|
||||
note: emptyCategories.value.length === 0 ? 'Keine Luecke in der Kandidatenbasis.' : emptyCategories.value.slice(0, 3).map((category) => category.name).join(', '),
|
||||
to: '/admin/candidates',
|
||||
tone: emptyCategories.value.length > 0 ? 'border-rose-100 bg-rose-50 text-rose-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
key: 'pending-reviews',
|
||||
label: 'Offene Reviews',
|
||||
value: seasonDetail.value.pendingNominations.length,
|
||||
note: categoriesWithReviews.value.length === 0 ? 'Review-Queue ist leer.' : `${categoriesWithReviews.value.length} Kategorien betroffen.`,
|
||||
to: '/admin/nominations?review=1',
|
||||
tone: seasonDetail.value.pendingNominations.length > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
key: 'missing-winners',
|
||||
label: 'Gewinner fehlen',
|
||||
value: categoriesWithoutWinner.value.length,
|
||||
note: categoriesWithoutWinner.value.length === 0 ? 'Alle Gewinner sind gesetzt.' : 'Finalisierung auf der Gewinner-Seite abschliessen.',
|
||||
to: '/admin/winners',
|
||||
tone: categoriesWithoutWinner.value.length > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
categoryHealth,
|
||||
metricCards,
|
||||
readinessCards,
|
||||
insightCards,
|
||||
attentionItems,
|
||||
topCategories,
|
||||
maxVotes,
|
||||
winnerCoveragePct,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminAuditEntry, AdminAuditQueryOptions } from '../../types/awards'
|
||||
|
||||
const auditPageLimit = 80
|
||||
const allFilter = 'all'
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
'candidate.create': 'Kandidat angelegt',
|
||||
'candidate.delete': 'Kandidat gelöscht',
|
||||
'candidate.update': 'Kandidat aktualisiert',
|
||||
'category.create': 'Kategorie angelegt',
|
||||
'category.delete': 'Kategorie gelöscht',
|
||||
'category.update': 'Kategorie aktualisiert',
|
||||
'clip.delete': 'Clip gelöscht',
|
||||
'clip.status.update': 'Clip-Status geändert',
|
||||
'nomination.approve': 'Nominierung übernommen',
|
||||
'nomination.reject': 'Nominierung verworfen',
|
||||
'operational-settings.update': 'Betriebseinstellungen geändert',
|
||||
'result.delete': 'Winner entfernt',
|
||||
'result.set': 'Winner gesetzt',
|
||||
'risk.resolve': 'Risk Flag entschieden',
|
||||
'season.create': 'Jahr angelegt',
|
||||
'season.delete': 'Jahr gelöscht',
|
||||
'season.phase.update': 'Phase geändert',
|
||||
'season.public.update': 'Public-Kontext geändert',
|
||||
'season.update': 'Jahr aktualisiert',
|
||||
'seed.initialize': 'Seed-Daten initialisiert',
|
||||
'site-settings.update': 'Seiteneinstellungen geändert',
|
||||
}
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
candidate: 'Kandidaten',
|
||||
category: 'Kategorien',
|
||||
clip: 'Clips',
|
||||
nomination: 'Reviews',
|
||||
result: 'Gewinner',
|
||||
risk: 'Risiko',
|
||||
seed: 'System',
|
||||
season: 'Jahre',
|
||||
site: 'Seite',
|
||||
'site-settings': 'Seite',
|
||||
'operational-settings': 'Betrieb',
|
||||
}
|
||||
|
||||
const relatedRoutes: Record<string, { label: string; to: string }> = {
|
||||
candidate: { label: 'Kandidaten öffnen', to: '/admin/candidates' },
|
||||
category: { label: 'Kategorien öffnen', to: '/admin/categories' },
|
||||
clip: { label: 'Clips öffnen', to: '/admin/clips' },
|
||||
nomination: { label: 'Review-Fokus öffnen', to: '/admin/nominations?review=1' },
|
||||
result: { label: 'Gewinner öffnen', to: '/admin/winners' },
|
||||
'risk-flag': { label: 'Risiko öffnen', to: '/admin/risk' },
|
||||
season: { label: 'Jahre öffnen', to: '/admin/years' },
|
||||
'site-settings': { label: 'Landingpage öffnen', to: '/admin/content' },
|
||||
'operational-settings': { label: 'Einstellungen öffnen', to: '/admin/settings' },
|
||||
}
|
||||
|
||||
export interface AuditStat {
|
||||
label: string
|
||||
value: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface AuditCountItem {
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AuditFocusCard {
|
||||
label: string
|
||||
value: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface AuditMetadataItem {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface AuditChangeItem {
|
||||
field: string
|
||||
label: string
|
||||
from: string
|
||||
to: string
|
||||
sensitive: boolean
|
||||
}
|
||||
|
||||
export interface AuditRelatedLink {
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface AuditFilterPreset {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
filters: {
|
||||
query?: string
|
||||
action?: string
|
||||
entityType?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditEntityOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AuditLogRow extends AdminAuditEntry {
|
||||
actionLabel: string
|
||||
actionGroup: string
|
||||
actionToneClass: string
|
||||
dotClass: string
|
||||
entityLabel: string
|
||||
createdLabel: string
|
||||
ageLabel: string
|
||||
metadataItems: AuditMetadataItem[]
|
||||
changeItems: AuditChangeItem[]
|
||||
requestContextItems: AuditMetadataItem[]
|
||||
relatedLink: AuditRelatedLink | null
|
||||
rawMetadataJson: string
|
||||
}
|
||||
|
||||
const entityOptions: AuditEntityOption[] = [
|
||||
{ value: allFilter, label: 'Alle Objekte' },
|
||||
{ value: 'season', label: 'Jahre' },
|
||||
{ value: 'category', label: 'Kategorien' },
|
||||
{ value: 'candidate', label: 'Kandidaten' },
|
||||
{ value: 'nomination', label: 'Reviews' },
|
||||
{ value: 'clip', label: 'Clips' },
|
||||
{ value: 'risk-flag', label: 'Risiko' },
|
||||
{ value: 'result', label: 'Gewinner' },
|
||||
{ value: 'site-settings', label: 'Landingpage' },
|
||||
{ value: 'operational-settings', label: 'Betrieb' },
|
||||
{ value: 'seed', label: 'System' },
|
||||
]
|
||||
|
||||
const filterPresets: AuditFilterPreset[] = [
|
||||
{
|
||||
key: 'risk-security',
|
||||
label: 'Risk & Security',
|
||||
description: 'Entscheidungen mit Sicherheits- oder Missbrauchskontext.',
|
||||
filters: { action: 'risk.resolve' },
|
||||
},
|
||||
{
|
||||
key: 'public-context',
|
||||
label: 'Public-Kontext',
|
||||
description: 'Jahreswechsel, Phasen und sichtbare Public-Änderungen.',
|
||||
filters: { entityType: 'season' },
|
||||
},
|
||||
{
|
||||
key: 'content-privacy',
|
||||
label: 'Content & Privacy',
|
||||
description: 'Landingpage, Datenschutz und Content-Konfiguration.',
|
||||
filters: { action: 'site-settings.update' },
|
||||
},
|
||||
{
|
||||
key: 'destructive',
|
||||
label: 'Löschungen',
|
||||
description: 'Entfernende Aktionen aus Kategorien, Kandidaten, Clips und Jahren.',
|
||||
filters: { query: 'delete' },
|
||||
},
|
||||
]
|
||||
|
||||
function countBy<T>(items: T[], getKey: (item: T) => string) {
|
||||
const counts = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const key = getKey(item).trim() || 'unbekannt'
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return [...counts.entries()]
|
||||
.map(([key, count]) => ({ key, label: key, count }))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
function humanizeAction(action: string) {
|
||||
if (actionLabels[action]) return actionLabels[action]
|
||||
return action
|
||||
.split('.')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).replaceAll('_', ' '))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function getActionGroup(action: string) {
|
||||
const group = action.split('.')[0] ?? ''
|
||||
return groupLabels[group] ?? 'System'
|
||||
}
|
||||
|
||||
function getActionToneClass(action: string) {
|
||||
if (action.startsWith('risk')) return 'border-rose-100 bg-rose-50 text-rose-700'
|
||||
if (action.startsWith('clip') || action.startsWith('nomination')) return 'border-amber-100 bg-amber-50 text-amber-700'
|
||||
if (action.startsWith('result')) return 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
if (action.startsWith('season')) return 'border-sky-100 bg-sky-50 text-sky-700'
|
||||
if (action.startsWith('site') || action.startsWith('operational')) return 'border-indigo-100 bg-indigo-50 text-indigo-700'
|
||||
if (action.includes('delete')) return 'border-slate-200 bg-slate-100 text-slate-700'
|
||||
return 'border-cyan-100 bg-cyan-50 text-cyan-700'
|
||||
}
|
||||
|
||||
function getDotClass(action: string) {
|
||||
if (action.startsWith('risk')) return 'bg-rose-400'
|
||||
if (action.startsWith('clip') || action.startsWith('nomination')) return 'bg-amber-400'
|
||||
if (action.startsWith('result')) return 'bg-emerald-400'
|
||||
if (action.startsWith('season')) return 'bg-sky-400'
|
||||
if (action.startsWith('site') || action.startsWith('operational')) return 'bg-indigo-400'
|
||||
if (action.includes('delete')) return 'bg-slate-400'
|
||||
return 'bg-cyan-400'
|
||||
}
|
||||
|
||||
function stringifyMetadataValue(entryValue: unknown) {
|
||||
if (typeof entryValue === 'object') return JSON.stringify(entryValue)
|
||||
return String(entryValue)
|
||||
}
|
||||
|
||||
function parseMetadata(metadataJson: string | undefined) {
|
||||
if (!metadataJson) return []
|
||||
|
||||
try {
|
||||
const value = JSON.parse(metadataJson) as Record<string, unknown>
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return []
|
||||
|
||||
return Object.entries(value)
|
||||
.filter(([key]) => key !== 'changes' && key !== 'Changes')
|
||||
.filter(([, entryValue]) => entryValue !== null && entryValue !== undefined && entryValue !== '')
|
||||
.map(([key, entryValue]) => ({
|
||||
key,
|
||||
value: stringifyMetadataValue(entryValue),
|
||||
}))
|
||||
} catch {
|
||||
return [{ key: 'metadata', value: metadataJson }]
|
||||
}
|
||||
}
|
||||
|
||||
function parseChangeItems(metadataJson: string | undefined): AuditChangeItem[] {
|
||||
if (!metadataJson) return []
|
||||
|
||||
try {
|
||||
const value = JSON.parse(metadataJson) as Record<string, unknown>
|
||||
const changes = value.changes ?? value.Changes
|
||||
if (!Array.isArray(changes)) return []
|
||||
|
||||
return changes
|
||||
.map((change) => {
|
||||
if (!change || typeof change !== 'object' || Array.isArray(change)) return null
|
||||
|
||||
const item = change as Record<string, unknown>
|
||||
return {
|
||||
field: String(item.field ?? item.Field ?? ''),
|
||||
label: String(item.label ?? item.Label ?? item.field ?? item.Field ?? 'Änderung'),
|
||||
from: String(item.from ?? item.From ?? ''),
|
||||
to: String(item.to ?? item.To ?? ''),
|
||||
sensitive: Boolean(item.sensitive ?? item.Sensitive),
|
||||
}
|
||||
})
|
||||
.filter((change): change is AuditChangeItem => Boolean(change?.field || change?.label))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
function formatAge(value: string) {
|
||||
const timestamp = new Date(value).getTime()
|
||||
if (Number.isNaN(timestamp)) return 'Zeitpunkt unbekannt'
|
||||
|
||||
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000))
|
||||
if (minutes < 2) return 'gerade eben'
|
||||
if (minutes < 60) return `vor ${minutes} Min.`
|
||||
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 48) return `vor ${hours} Std.`
|
||||
|
||||
return `vor ${Math.round(hours / 24)} Tagen`
|
||||
}
|
||||
|
||||
function escapeCsvCell(value: unknown) {
|
||||
const text = String(value ?? '')
|
||||
if (!/[",\n\r;]/.test(text)) return text
|
||||
return `"${text.replaceAll('"', '""')}"`
|
||||
}
|
||||
|
||||
function getDateBoundary(value: string, isEndOfDay: boolean) {
|
||||
if (!value) return undefined
|
||||
const suffix = isEndOfDay ? 'T23:59:59.999' : 'T00:00:00.000'
|
||||
const date = new Date(`${value}${suffix}`)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
||||
}
|
||||
|
||||
function buildRequestContextItems(entry: AdminAuditEntry) {
|
||||
return [
|
||||
{ key: 'IP', value: entry.createdFromIp || 'nicht erfasst' },
|
||||
{ key: 'User-Agent', value: entry.userAgent || 'nicht erfasst' },
|
||||
]
|
||||
}
|
||||
|
||||
function buildRelatedLink(entry: AdminAuditEntry) {
|
||||
const route = relatedRoutes[entry.entityType]
|
||||
if (!route) return null
|
||||
|
||||
if (entry.entityType === 'nomination' && entry.entityId) {
|
||||
return { label: route.label, to: `${route.to}?nominationId=${encodeURIComponent(entry.entityId)}` }
|
||||
}
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
function createAuditRow(entry: AdminAuditEntry): AuditLogRow {
|
||||
return {
|
||||
...entry,
|
||||
actionLabel: humanizeAction(entry.actionType),
|
||||
actionGroup: getActionGroup(entry.actionType),
|
||||
actionToneClass: getActionToneClass(entry.actionType),
|
||||
dotClass: getDotClass(entry.actionType),
|
||||
entityLabel: `${entry.entityType} ${entry.entityId}`.trim(),
|
||||
createdLabel: formatDate(entry.createdAt),
|
||||
ageLabel: formatAge(entry.createdAt),
|
||||
metadataItems: parseMetadata(entry.metadataJson),
|
||||
changeItems: parseChangeItems(entry.metadataJson),
|
||||
requestContextItems: buildRequestContextItems(entry),
|
||||
relatedLink: buildRelatedLink(entry),
|
||||
rawMetadataJson: entry.metadataJson || '{}',
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCsv(entries: AdminAuditEntry[]) {
|
||||
const rows = [
|
||||
[
|
||||
'Id',
|
||||
'Admin',
|
||||
'Aktion',
|
||||
'Objekt-Typ',
|
||||
'Objekt-Id',
|
||||
'Zusammenfassung',
|
||||
'Zeitpunkt',
|
||||
'IP',
|
||||
'User-Agent',
|
||||
'Metadaten',
|
||||
],
|
||||
...entries.map((entry) => [
|
||||
entry.id,
|
||||
entry.adminTwitchUserId,
|
||||
entry.actionType,
|
||||
entry.entityType,
|
||||
entry.entityId,
|
||||
entry.summary,
|
||||
new Date(entry.createdAt).toISOString(),
|
||||
entry.createdFromIp,
|
||||
entry.userAgent,
|
||||
entry.metadataJson,
|
||||
]),
|
||||
]
|
||||
|
||||
const csv = rows.map((row) => row.map(escapeCsvCell).join(';')).join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `vtuber-star-awards-audit-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function useAdminAuditManager() {
|
||||
const store = useAwardsStore()
|
||||
const query = ref('')
|
||||
const selectedAdmin = ref(allFilter)
|
||||
const selectedAction = ref(allFilter)
|
||||
const entityFilter = ref(allFilter)
|
||||
const fromDate = ref('')
|
||||
const toDate = ref('')
|
||||
const loadingAudit = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const auditError = ref('')
|
||||
const exportMessage = ref('')
|
||||
const lastLoadedAt = ref<Date | null>(null)
|
||||
const auditEntries = ref<AdminAuditEntry[]>([])
|
||||
const totalCount = ref(0)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const selectedEntry = ref<AuditLogRow | null>(null)
|
||||
const appliedPresetKey = ref('')
|
||||
let searchTimer: ReturnType<typeof window.setTimeout> | null = null
|
||||
let exportMessageTimer: ReturnType<typeof window.setTimeout> | null = null
|
||||
let suppressFilterWatch = false
|
||||
|
||||
const adminCounts = computed(() => countBy(auditEntries.value, (entry) => entry.adminTwitchUserId))
|
||||
const actionCounts = computed(() =>
|
||||
countBy(auditEntries.value, (entry) => entry.actionType).map((item) => ({
|
||||
...item,
|
||||
label: humanizeAction(item.key),
|
||||
})),
|
||||
)
|
||||
const entityCounts = computed(() =>
|
||||
countBy(auditEntries.value, (entry) => entry.entityType).map((item) => ({
|
||||
...item,
|
||||
label: entityOptions.find((option) => option.value === item.key)?.label ?? item.key,
|
||||
})),
|
||||
)
|
||||
const auditRows = computed<AuditLogRow[]>(() => auditEntries.value.map(createAuditRow))
|
||||
const metadataCount = computed(() => auditEntries.value.filter((entry) => parseMetadata(entry.metadataJson).length > 0).length)
|
||||
const requestContextCount = computed(() => auditEntries.value.filter((entry) => entry.createdFromIp || entry.userAgent).length)
|
||||
const recentDayCount = computed(() => {
|
||||
const minTimestamp = Date.now() - 24 * 60 * 60 * 1000
|
||||
return auditEntries.value.filter((entry) => new Date(entry.createdAt).getTime() >= minTimestamp).length
|
||||
})
|
||||
const hasMore = computed(() => Boolean(nextCursor.value))
|
||||
const loadedCountLabel = computed(() => auditEntries.value.length.toLocaleString('de-DE'))
|
||||
const totalCountLabel = computed(() => totalCount.value.toLocaleString('de-DE'))
|
||||
const logStats = computed<AuditStat[]>(() => [
|
||||
{ label: 'Geladen', value: loadedCountLabel.value, note: `Page ${auditPageLimit}` },
|
||||
{ label: 'Treffer', value: totalCountLabel.value, note: 'serverseitig gefiltert' },
|
||||
{ label: '24h', value: recentDayCount.value.toLocaleString('de-DE'), note: 'neue Aktionen' },
|
||||
{ label: 'Kontext', value: requestContextCount.value.toLocaleString('de-DE'), note: 'mit IP oder User-Agent' },
|
||||
])
|
||||
const focusCards = computed<AuditFocusCard[]>(() => {
|
||||
const topAdmin = adminCounts.value[0]
|
||||
const topAction = actionCounts.value[0]
|
||||
const topEntity = entityCounts.value[0]
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Top Admin',
|
||||
value: topAdmin?.label ?? 'Keine Daten',
|
||||
note: topAdmin ? `${topAdmin.count} Aktionen in den geladenen Treffern` : 'Noch kein Admin aktiv',
|
||||
},
|
||||
{
|
||||
label: 'Top Aktion',
|
||||
value: topAction?.label ?? 'Keine Daten',
|
||||
note: topAction ? `${topAction.count} Einträge` : 'Noch kein Aktionstyp vorhanden',
|
||||
},
|
||||
{
|
||||
label: 'Objekt-Fokus',
|
||||
value: topEntity?.label ?? 'Keine Daten',
|
||||
note: topEntity ? `${topEntity.count} Einträge` : 'Noch kein Objekttyp vorhanden',
|
||||
},
|
||||
{
|
||||
label: 'Details',
|
||||
value: metadataCount.value.toLocaleString('de-DE'),
|
||||
note: 'Einträge mit sichtbaren Metadaten',
|
||||
},
|
||||
]
|
||||
})
|
||||
const activeFilterCount = computed(() =>
|
||||
(query.value.trim() ? 1 : 0) +
|
||||
(selectedAdmin.value !== allFilter ? 1 : 0) +
|
||||
(selectedAction.value !== allFilter ? 1 : 0) +
|
||||
(entityFilter.value !== allFilter ? 1 : 0) +
|
||||
(fromDate.value ? 1 : 0) +
|
||||
(toDate.value ? 1 : 0),
|
||||
)
|
||||
const lastLoadedLabel = computed(() =>
|
||||
lastLoadedAt.value ? lastLoadedAt.value.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : 'Noch nicht aktualisiert',
|
||||
)
|
||||
const emptyStateText = computed(() =>
|
||||
activeFilterCount.value > 0
|
||||
? 'Keine Log-Einträge passen zu den aktiven Filtern.'
|
||||
: 'Noch keine Audit-Einträge vorhanden.',
|
||||
)
|
||||
const pageSummaryLabel = computed(() =>
|
||||
`${loadedCountLabel.value} von ${totalCountLabel.value} Treffern geladen`,
|
||||
)
|
||||
|
||||
function buildAuditOptions(cursor: string | null = null): AdminAuditQueryOptions {
|
||||
return {
|
||||
limit: auditPageLimit,
|
||||
query: query.value,
|
||||
admin: selectedAdmin.value === allFilter ? undefined : selectedAdmin.value,
|
||||
action: selectedAction.value === allFilter ? undefined : selectedAction.value,
|
||||
entityType: entityFilter.value === allFilter ? undefined : entityFilter.value,
|
||||
from: getDateBoundary(fromDate.value, false),
|
||||
to: getDateBoundary(toDate.value, true),
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (searchTimer) {
|
||||
window.clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLoad() {
|
||||
clearTimers()
|
||||
searchTimer = window.setTimeout(() => {
|
||||
void loadAuditEntries()
|
||||
}, 350)
|
||||
}
|
||||
|
||||
async function loadAuditEntries(options: { append?: boolean } = {}) {
|
||||
const append = options.append ?? false
|
||||
if (append && !nextCursor.value) return
|
||||
|
||||
if (append) {
|
||||
loadingMore.value = true
|
||||
} else {
|
||||
loadingAudit.value = true
|
||||
selectedEntry.value = null
|
||||
}
|
||||
|
||||
auditError.value = ''
|
||||
|
||||
try {
|
||||
const response = await store.loadAdminAuditEntriesPage(
|
||||
buildAuditOptions(append ? nextCursor.value : null),
|
||||
append,
|
||||
)
|
||||
auditEntries.value = append ? [...auditEntries.value, ...response.items] : response.items
|
||||
totalCount.value = response.totalCount
|
||||
nextCursor.value = response.nextCursor
|
||||
lastLoadedAt.value = new Date()
|
||||
} catch (error) {
|
||||
auditError.value = error instanceof Error ? error.message : 'Audit-Logs konnten nicht geladen werden.'
|
||||
} finally {
|
||||
loadingAudit.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNextPage() {
|
||||
await loadAuditEntries({ append: true })
|
||||
}
|
||||
|
||||
function exportAuditCsv() {
|
||||
if (auditEntries.value.length === 0) return
|
||||
downloadCsv(auditEntries.value)
|
||||
exportMessage.value = `CSV mit ${auditEntries.value.length.toLocaleString('de-DE')} geladenen Einträgen erstellt.`
|
||||
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
|
||||
exportMessageTimer = window.setTimeout(() => {
|
||||
exportMessage.value = ''
|
||||
}, 3500)
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
clearTimers()
|
||||
suppressFilterWatch = true
|
||||
query.value = ''
|
||||
selectedAdmin.value = allFilter
|
||||
selectedAction.value = allFilter
|
||||
entityFilter.value = allFilter
|
||||
fromDate.value = ''
|
||||
toDate.value = ''
|
||||
appliedPresetKey.value = ''
|
||||
suppressFilterWatch = false
|
||||
await loadAuditEntries()
|
||||
}
|
||||
|
||||
async function applyPreset(presetKey: string) {
|
||||
const preset = filterPresets.find((item) => item.key === presetKey)
|
||||
if (!preset) return
|
||||
|
||||
clearTimers()
|
||||
suppressFilterWatch = true
|
||||
query.value = preset.filters.query ?? ''
|
||||
selectedAdmin.value = allFilter
|
||||
selectedAction.value = preset.filters.action ?? allFilter
|
||||
entityFilter.value = preset.filters.entityType ?? allFilter
|
||||
fromDate.value = ''
|
||||
toDate.value = ''
|
||||
appliedPresetKey.value = preset.key
|
||||
suppressFilterWatch = false
|
||||
await loadAuditEntries()
|
||||
}
|
||||
|
||||
function openAuditEntry(entry: AuditLogRow) {
|
||||
selectedEntry.value = entry
|
||||
}
|
||||
|
||||
function closeAuditEntry() {
|
||||
selectedEntry.value = null
|
||||
}
|
||||
|
||||
watch([query, selectedAdmin, selectedAction, entityFilter, fromDate, toDate], () => {
|
||||
if (suppressFilterWatch) return
|
||||
appliedPresetKey.value = ''
|
||||
scheduleLoad()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
auditEntries.value = store.admin.auditEntries
|
||||
totalCount.value = store.admin.auditEntries.length
|
||||
void loadAuditEntries()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers()
|
||||
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
query,
|
||||
selectedAdmin,
|
||||
selectedAction,
|
||||
entityFilter,
|
||||
fromDate,
|
||||
toDate,
|
||||
loadingAudit,
|
||||
loadingMore,
|
||||
auditError,
|
||||
exportMessage,
|
||||
selectedEntry,
|
||||
auditRows,
|
||||
adminCounts,
|
||||
actionCounts,
|
||||
entityCounts,
|
||||
logStats,
|
||||
focusCards,
|
||||
activeFilterCount,
|
||||
lastLoadedLabel,
|
||||
emptyStateText,
|
||||
pageSummaryLabel,
|
||||
hasMore,
|
||||
filterPresets,
|
||||
appliedPresetKey,
|
||||
entityOptions,
|
||||
loadAuditEntries,
|
||||
loadNextPage,
|
||||
exportAuditCsv,
|
||||
clearFilters,
|
||||
applyPreset,
|
||||
openAuditEntry,
|
||||
closeAuditEntry,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
|
||||
interface CandidateForm {
|
||||
categoryId: number
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
const pageSize = 10
|
||||
|
||||
export function useAdminCandidateManager() {
|
||||
const store = useAwardsStore()
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const search = ref('')
|
||||
const categoryFilter = ref<number | null>(null)
|
||||
const page = ref(1)
|
||||
const modalOpen = ref(false)
|
||||
const editingId = ref<number | 'new' | null>(null)
|
||||
const candidateToDelete = ref<AdminCandidateItem | null>(null)
|
||||
const form = reactive<CandidateForm>({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const categoryOptions = computed(() =>
|
||||
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
|
||||
)
|
||||
const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
|
||||
const categoryLabelMap = computed<Record<number, string>>(() =>
|
||||
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])),
|
||||
)
|
||||
const duplicateCandidateKeys = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const candidate of seasonDetail.value.candidates) {
|
||||
const nameKey = createCandidateDuplicateKey(candidate, 'name')
|
||||
const slugKey = createCandidateDuplicateKey(candidate, 'slug')
|
||||
counts.set(nameKey, (counts.get(nameKey) ?? 0) + 1)
|
||||
if (candidate.channelSlug.trim()) {
|
||||
counts.set(slugKey, (counts.get(slugKey) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const duplicateCandidateCount = computed(() =>
|
||||
seasonDetail.value.candidates.filter((candidate) => hasDuplicateCandidateKey(candidate, duplicateCandidateKeys.value)).length,
|
||||
)
|
||||
const filteredCandidates = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
let list = seasonDetail.value.candidates
|
||||
|
||||
if (categoryFilter.value) {
|
||||
list = list.filter((candidate) => candidate.categoryId === categoryFilter.value)
|
||||
}
|
||||
|
||||
if (query) {
|
||||
list = list.filter((candidate) =>
|
||||
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
|
||||
const pagedCandidates = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return filteredCandidates.value.slice(start, start + pageSize)
|
||||
})
|
||||
const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
|
||||
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
|
||||
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
|
||||
const canSave = computed(() =>
|
||||
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim() && form.platform.trim()),
|
||||
)
|
||||
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
|
||||
const selectedPlatformValue = computed(() => socialIconOptionForValue(form.platform)?.key ?? 'custom')
|
||||
|
||||
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
watch(totalPages, (max) => {
|
||||
if (page.value > max) {
|
||||
page.value = max
|
||||
}
|
||||
})
|
||||
|
||||
function clearFilters() {
|
||||
search.value = ''
|
||||
categoryFilter.value = null
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
editingId.value = 'new'
|
||||
form.categoryId = categoryFilter.value ?? seasonDetail.value.categories[0]?.id ?? 0
|
||||
form.displayName = ''
|
||||
form.channelSlug = ''
|
||||
form.platform = 'Twitch'
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(candidate: AdminCandidateItem) {
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
editingId.value = candidate.id
|
||||
form.categoryId = candidate.categoryId
|
||||
form.displayName = candidate.displayName
|
||||
form.channelSlug = candidate.channelSlug
|
||||
form.platform = candidate.platform
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function handlePlatformSelection(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
if (value === 'custom') {
|
||||
if (socialIconOptionForValue(form.platform)) {
|
||||
form.platform = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const option = socialIconOptionForValue(value)
|
||||
form.platform = option?.label ?? value
|
||||
}
|
||||
|
||||
async function saveModal() {
|
||||
if (!canSave.value || !selectedSeasonId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
if (editingId.value === 'new') {
|
||||
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
|
||||
adminMessage.value = `„${form.displayName}" wurde angelegt.`
|
||||
} else if (typeof editingId.value === 'number') {
|
||||
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
|
||||
adminMessage.value = `„${form.displayName}" wurde gespeichert.`
|
||||
}
|
||||
modalOpen.value = false
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!candidateToDelete.value || !selectedSeasonId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
|
||||
adminMessage.value = `„${candidateToDelete.value.displayName}" wurde gelöscht.`
|
||||
candidateToDelete.value = null
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
seasonDetail,
|
||||
saving,
|
||||
deleting,
|
||||
adminMessage,
|
||||
adminError,
|
||||
search,
|
||||
categoryFilter,
|
||||
page,
|
||||
categoryOptions,
|
||||
categoryFilterOptions,
|
||||
categoryLabelMap,
|
||||
duplicateCandidateKeys,
|
||||
duplicateCandidateCount,
|
||||
filteredCandidates,
|
||||
pagedCandidates,
|
||||
totalPages,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
modalOpen,
|
||||
form,
|
||||
modalTitle,
|
||||
canSave,
|
||||
candidatePlatformOptions,
|
||||
selectedPlatformValue,
|
||||
candidateToDelete,
|
||||
clearFilters,
|
||||
openCreate,
|
||||
openEdit,
|
||||
handlePlatformSelection,
|
||||
saveModal,
|
||||
confirmDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function createCandidateDuplicateKey(candidate: AdminCandidateItem, field: 'name' | 'slug') {
|
||||
const value = field === 'name' ? candidate.displayName : candidate.channelSlug
|
||||
return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}`
|
||||
}
|
||||
|
||||
function hasDuplicateCandidateKey(candidate: AdminCandidateItem, candidateKeys: Map<string, number>) {
|
||||
return (candidateKeys.get(createCandidateDuplicateKey(candidate, 'name')) ?? 0) > 1
|
||||
|| (candidateKeys.get(createCandidateDuplicateKey(candidate, 'slug')) ?? 0) > 1
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminCategoryItem } from '../../types/awards'
|
||||
|
||||
type CategoryStatusFilter = 'all' | 'empty' | 'reviews' | 'thin'
|
||||
|
||||
interface CategoryForm {
|
||||
groupName: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
}
|
||||
|
||||
export function useAdminCategoryManager() {
|
||||
const store = useAwardsStore()
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const query = ref('')
|
||||
const statusFilter = ref<CategoryStatusFilter>('all')
|
||||
const selectedCategoryId = ref<number | null>(null)
|
||||
const saving = ref<number | 'new' | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const categoryToDelete = ref<AdminCategoryItem | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const editForms = reactive<Record<number, CategoryForm>>({})
|
||||
const newCategoryForm = reactive(createEmptyCategoryForm())
|
||||
|
||||
const categoriesWithState = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
)
|
||||
const filteredCategories = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
return categoriesWithState.value.filter((category) => {
|
||||
const matchesStatus =
|
||||
statusFilter.value === 'all' ||
|
||||
(statusFilter.value === 'empty' && category.candidates === 0) ||
|
||||
(statusFilter.value === 'reviews' && category.pending > 0) ||
|
||||
(statusFilter.value === 'thin' && category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser))
|
||||
const matchesSearch = !search || [category.groupName, category.name, category.slug, category.description]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
return matchesStatus && matchesSearch
|
||||
})
|
||||
})
|
||||
const selectedCategory = computed(() =>
|
||||
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
|
||||
)
|
||||
const categoryStats = computed(() => [
|
||||
{ label: 'Kategorien', value: seasonDetail.value.categories.length },
|
||||
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length },
|
||||
{ label: 'Reviews', value: seasonDetail.value.pendingNominations.length },
|
||||
])
|
||||
const statusFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length },
|
||||
{ key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length },
|
||||
{ key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length },
|
||||
{ key: 'thin' as const, label: 'Dünn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length },
|
||||
])
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
for (const category of detail.categories) {
|
||||
editForms[category.id] = createCategoryForm(category)
|
||||
}
|
||||
newCategoryForm.sortOrder = detail.categories.length + 1
|
||||
if (!detail.categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = detail.categories[0]?.id ?? null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(filteredCategories, (categories) => {
|
||||
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = categories[0]?.id ?? null
|
||||
}
|
||||
})
|
||||
|
||||
async function saveCategory(categoryId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
saving.value = categoryId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
|
||||
adminMessage.value = 'Kategorie gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
if (!selectedSeasonId.value) return
|
||||
saving.value = 'new'
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
|
||||
adminMessage.value = 'Kategorie angelegt.'
|
||||
Object.assign(newCategoryForm, createEmptyCategoryForm(seasonDetail.value.categories.length + 1))
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function fillNewSlug() {
|
||||
newCategoryForm.slug = slugify(newCategoryForm.name)
|
||||
}
|
||||
|
||||
async function confirmDeleteCategory() {
|
||||
if (!categoryToDelete.value || !selectedSeasonId.value) return
|
||||
deleting.value = true
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.deleteAdminCategory(categoryToDelete.value.id, selectedSeasonId.value)
|
||||
adminMessage.value = `Kategorie „${categoryToDelete.value.name}" wurde gelöscht.`
|
||||
categoryToDelete.value = null
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedSeasonId,
|
||||
query,
|
||||
statusFilter,
|
||||
selectedCategoryId,
|
||||
saving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
editForms,
|
||||
newCategoryForm,
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
categoryStats,
|
||||
statusFilters,
|
||||
categoryToDelete,
|
||||
deleting,
|
||||
saveCategory,
|
||||
createCategory,
|
||||
fillNewSlug,
|
||||
confirmDeleteCategory,
|
||||
}
|
||||
}
|
||||
|
||||
function createCategoryForm(category: AdminCategoryItem): CategoryForm
|
||||
function createCategoryForm(category?: AdminCategoryItem, sortOrder?: number): CategoryForm
|
||||
function createCategoryForm(category?: AdminCategoryItem, sortOrder = 1): CategoryForm {
|
||||
return {
|
||||
groupName: category?.groupName ?? '',
|
||||
name: category?.name ?? '',
|
||||
slug: category?.slug ?? '',
|
||||
description: category?.description ?? '',
|
||||
sortOrder: category?.sortOrder ?? sortOrder,
|
||||
maxNomineesPerUser: category?.maxNomineesPerUser ?? 3,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyCategoryForm(sortOrder = 1): CategoryForm {
|
||||
return createCategoryForm(undefined, sortOrder)
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { CheckCircle2, Film, Layers3, PlayCircle } from '@lucide/vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminClipSubmissionItem } from '../../types/awards'
|
||||
|
||||
type ClipReviewStatus = 'pending' | 'approved' | 'rejected'
|
||||
type ClipStatusFilter = 'all' | 'pending' | 'reviewed'
|
||||
type ClipPlatformFilter = 'all' | string
|
||||
|
||||
export function useAdminClipManager() {
|
||||
const store = useAwardsStore()
|
||||
const query = ref('')
|
||||
const statusFilter = ref<ClipStatusFilter>('all')
|
||||
const platformFilter = ref<ClipPlatformFilter>('all')
|
||||
const categoryFilter = ref('all')
|
||||
const deleting = ref(false)
|
||||
const statusSaving = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
|
||||
const reviewNotes = reactive<Record<number, string>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
|
||||
const categories = computed(() => seasonDetail.value.categories ?? [])
|
||||
const categoryName = computed(() =>
|
||||
Object.fromEntries(categories.value.map((category) => [category.id, category.name])),
|
||||
)
|
||||
const clips = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
return submissions.value.filter((clip) =>
|
||||
matchesStatusFilter(clip.status, statusFilter.value) &&
|
||||
(platformFilter.value === 'all' || clip.platform === platformFilter.value) &&
|
||||
(categoryFilter.value === 'all' || String(clip.categoryId) === categoryFilter.value) &&
|
||||
(!search || [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search)),
|
||||
)
|
||||
})
|
||||
const duplicateUrls = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const clip of submissions.value) {
|
||||
const key = normalizeClipUrl(clip.clipUrl)
|
||||
if (!key) continue
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const creatorClipGroups = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const clip of submissions.value) {
|
||||
const key = creatorClipKey(clip)
|
||||
if (!key) continue
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const stats = computed(() => [
|
||||
{ label: 'Einreichungen', value: submissions.value.length, icon: Film },
|
||||
{ label: 'Offen', value: submissions.value.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
|
||||
{ label: 'Freigegeben', value: submissions.value.filter((clip) => clip.status === 'approved').length, icon: CheckCircle2 },
|
||||
{ label: 'Duplikate', value: [...duplicateUrls.value.values()].filter((count) => count > 1).length, icon: Layers3 },
|
||||
])
|
||||
const statusFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: submissions.value.length },
|
||||
{ key: 'pending' as const, label: 'Offen', count: submissions.value.filter((clip) => clip.status === 'pending').length },
|
||||
{ key: 'reviewed' as const, label: 'Geprüft', count: submissions.value.filter((clip) => clip.status !== 'pending').length },
|
||||
])
|
||||
const platformFilters = computed(() => [
|
||||
{ key: 'all', label: 'Alle Plattformen', count: submissions.value.length },
|
||||
...[...new Set(submissions.value.map((clip) => clip.platform).filter(Boolean))]
|
||||
.sort()
|
||||
.map((platform) => ({
|
||||
key: platform,
|
||||
label: platform,
|
||||
count: submissions.value.filter((clip) => clip.platform === platform).length,
|
||||
})),
|
||||
])
|
||||
const categoryFilters = computed(() => [
|
||||
{ id: 'all' as const, label: 'Alle Kategorien', count: submissions.value.length },
|
||||
...categories.value
|
||||
.filter((category) => submissions.value.some((clip) => clip.categoryId === category.id))
|
||||
.map((category) => ({
|
||||
id: String(category.id),
|
||||
label: category.name,
|
||||
count: submissions.value.filter((clip) => clip.categoryId === category.id).length,
|
||||
})),
|
||||
])
|
||||
|
||||
watch(submissions, (items) => {
|
||||
for (const clip of items) {
|
||||
if (reviewNotes[clip.id] === undefined) {
|
||||
reviewNotes[clip.id] = clip.reviewNote ?? ''
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function updateClipStatus(clip: AdminClipSubmissionItem, status: ClipReviewStatus) {
|
||||
if (!selectedSeasonId.value) return
|
||||
statusSaving.value = clip.id
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.updateAdminClipStatus(clip.id, selectedSeasonId.value, {
|
||||
status,
|
||||
reviewNote: reviewNotes[clip.id]?.trim() || undefined,
|
||||
})
|
||||
adminMessage.value = `Clip ${clip.id} wurde auf „${statusLabel(status)}“ gesetzt.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Clip-Status konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
statusSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!clipToDelete.value || !selectedSeasonId.value) return
|
||||
deleting.value = true
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.deleteAdminClip(clipToDelete.value.id, selectedSeasonId.value)
|
||||
adminMessage.value = 'Clip-Einreichung wurde entfernt.'
|
||||
clipToDelete.value = null
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateUrlCount(clipUrl: string) {
|
||||
return duplicateUrls.value.get(normalizeClipUrl(clipUrl)) ?? 0
|
||||
}
|
||||
|
||||
function creatorClipCount(clip: AdminClipSubmissionItem) {
|
||||
return creatorClipGroups.value.get(creatorClipKey(clip)) ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
statusFilter,
|
||||
platformFilter,
|
||||
categoryFilter,
|
||||
deleting,
|
||||
statusSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
clipToDelete,
|
||||
reviewNotes,
|
||||
submissions,
|
||||
categoryName,
|
||||
clips,
|
||||
stats,
|
||||
statusFilters,
|
||||
platformFilters,
|
||||
categoryFilters,
|
||||
platformClass,
|
||||
statusClass,
|
||||
statusLabel,
|
||||
duplicateUrlCount,
|
||||
creatorClipCount,
|
||||
updateClipStatus,
|
||||
confirmDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function matchesStatusFilter(status: string, filter: ClipStatusFilter) {
|
||||
return filter === 'all' || status === filter || (filter === 'reviewed' && status !== 'pending')
|
||||
}
|
||||
|
||||
function normalizeClipUrl(clipUrl: string) {
|
||||
return clipUrl.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function creatorClipKey(clip: AdminClipSubmissionItem) {
|
||||
if (clip.candidateId) return `candidate:${clip.candidateId}`
|
||||
const creator = clip.creator.trim().toLowerCase()
|
||||
const categoryId = clip.categoryId ?? 'global'
|
||||
return creator ? `creator:${categoryId}:${creator}` : ''
|
||||
}
|
||||
|
||||
function platformClass(platform: string) {
|
||||
if (platform === 'Twitch') return 'border-violet-200 bg-violet-50 text-violet-700'
|
||||
if (platform === 'YouTube') return 'border-rose-200 bg-rose-50 text-rose-600'
|
||||
return 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
}
|
||||
|
||||
function statusClass(status: string) {
|
||||
if (status === 'approved') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
if (status === 'rejected') return 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
return 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'approved') return 'freigegeben'
|
||||
if (status === 'rejected') return 'abgelehnt'
|
||||
return 'offen'
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
normalizeSocialIconKey,
|
||||
simpleIconForKey,
|
||||
socialIconOptionForKey,
|
||||
} from '../../lib/socialIcons'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
|
||||
|
||||
function createEmptyForm(): AdminContentForm {
|
||||
return {
|
||||
hostDisplayName: '',
|
||||
hostTagline: '',
|
||||
newsletterUrl: '',
|
||||
privacyEmail: '',
|
||||
privacyPolicyContent: '',
|
||||
imprintUrl: '',
|
||||
contactUrl: '',
|
||||
sponsorsUrl: '',
|
||||
socialLinks: [],
|
||||
faq: [],
|
||||
}
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(new Error('Icon konnte nicht gelesen werden.'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function validateRasterDimensions(file: File) {
|
||||
if (file.type === 'image/svg+xml') {
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
if (image.naturalWidth > 512 || image.naturalHeight > 512) {
|
||||
reject(new Error('Icon ist zu groß. Maximal erlaubt sind 512 x 512 px.'))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
image.onerror = () => reject(new Error('Icon-Abmessungen konnten nicht geprüft werden.'))
|
||||
image.src = objectUrl
|
||||
})
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function useAdminContentManager() {
|
||||
const store = useAwardsStore()
|
||||
const form = reactive(createEmptyForm())
|
||||
const saving = ref(false)
|
||||
const saveMessage = ref('')
|
||||
const saveError = ref('')
|
||||
const privacyPreviewOpen = ref(false)
|
||||
const iconUploadError = ref('')
|
||||
|
||||
watch(
|
||||
() => store.adminSiteSettings,
|
||||
(settings) => {
|
||||
form.hostDisplayName = settings.hostDisplayName
|
||||
form.hostTagline = settings.hostTagline
|
||||
form.newsletterUrl = settings.newsletterUrl
|
||||
form.privacyEmail = settings.privacyEmail
|
||||
form.privacyPolicyContent = settings.privacyPolicyContent
|
||||
form.imprintUrl = settings.imprintUrl
|
||||
form.contactUrl = settings.contactUrl
|
||||
form.sponsorsUrl = settings.sponsorsUrl
|
||||
form.socialLinks = settings.socialLinks.map((item) => ({
|
||||
label: item.label ?? '',
|
||||
platform: item.platform ?? '',
|
||||
icon: item.icon ?? '',
|
||||
url: item.url ?? '',
|
||||
showOnHost: item.showOnHost ?? true,
|
||||
showOnCommunity: item.showOnCommunity ?? true,
|
||||
}))
|
||||
form.faq = settings.faq.map((item) => ({
|
||||
question: item.question ?? '',
|
||||
answer: item.answer ?? '',
|
||||
}))
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const privacyPreviewBlocks = computed(() =>
|
||||
form.privacyPolicyContent
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
const privacyUpdatedLabel = computed(() => {
|
||||
const updatedAt = store.adminSiteSettings.privacyPolicyUpdatedAt
|
||||
if (!updatedAt) {
|
||||
return 'Noch keine Änderung gespeichert'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(updatedAt))
|
||||
})
|
||||
|
||||
function addSocialLink() {
|
||||
form.socialLinks.push({
|
||||
label: 'Neu',
|
||||
platform: 'website',
|
||||
icon: '',
|
||||
url: '',
|
||||
showOnHost: true,
|
||||
showOnCommunity: true,
|
||||
})
|
||||
}
|
||||
|
||||
function removeSocialLink(index: number) {
|
||||
form.socialLinks.splice(index, 1)
|
||||
}
|
||||
|
||||
function addFaqItem() {
|
||||
form.faq.push({ question: '', answer: '' })
|
||||
}
|
||||
|
||||
function removeFaqItem(index: number) {
|
||||
form.faq.splice(index, 1)
|
||||
}
|
||||
|
||||
function isUploadedIcon(icon: string) {
|
||||
return icon.startsWith('data:image/')
|
||||
}
|
||||
|
||||
function socialIconKey(social: SocialLinkForm) {
|
||||
return normalizeSocialIconKey(social.icon || social.platform)
|
||||
}
|
||||
|
||||
function selectedSocialIconValue(social: SocialLinkForm) {
|
||||
return socialIconOptionForKey(social.platform)
|
||||
? normalizeSocialIconKey(social.platform)
|
||||
: 'custom'
|
||||
}
|
||||
|
||||
function handleSocialIconSelection(event: Event, index: number) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
const social = form.socialLinks[index]
|
||||
if (!social) {
|
||||
return
|
||||
}
|
||||
|
||||
if (value === 'custom') {
|
||||
if (socialIconOptionForKey(social.platform)) {
|
||||
social.platform = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const option = socialIconOptionForKey(value)
|
||||
social.platform = value
|
||||
if (!social.label || social.label === 'Neu' || social.label === 'Website / Fallback') {
|
||||
social.label = option?.label ?? value
|
||||
}
|
||||
if (social.icon && !isUploadedIcon(social.icon)) {
|
||||
social.icon = ''
|
||||
}
|
||||
}
|
||||
|
||||
function hasSocialIconPreview(social: SocialLinkForm) {
|
||||
return Boolean(social.label || social.platform || social.icon)
|
||||
}
|
||||
|
||||
function socialIconModeLabel(social: SocialLinkForm) {
|
||||
if (isUploadedIcon(social.icon)) {
|
||||
return 'hochgeladenes Custom Icon'
|
||||
}
|
||||
|
||||
const icon = simpleIconForKey(socialIconKey(social))
|
||||
if (icon) {
|
||||
return `eingebautes ${icon.title} Icon`
|
||||
}
|
||||
|
||||
return 'generisches Fallback Icon'
|
||||
}
|
||||
|
||||
function socialSimpleIconPath(social: SocialLinkForm) {
|
||||
return simpleIconForKey(socialIconKey(social))?.path ?? ''
|
||||
}
|
||||
|
||||
function socialSimpleIconColor(social: SocialLinkForm) {
|
||||
return `#${simpleIconForKey(socialIconKey(social))?.hex ?? '8b5cf6'}`
|
||||
}
|
||||
|
||||
async function handleSocialIconUpload(event: Event, index: number) {
|
||||
iconUploadError.value = ''
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
iconUploadError.value = 'Bitte PNG, JPG, WebP oder SVG hochladen.'
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = 256 * 1024
|
||||
if (file.size > maxBytes) {
|
||||
iconUploadError.value = 'Icon ist zu groß. Maximal erlaubt sind 256 KB.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await validateRasterDimensions(file)
|
||||
form.socialLinks[index].icon = await readFileAsDataUrl(file)
|
||||
} catch (error) {
|
||||
iconUploadError.value = error instanceof Error ? error.message : 'Icon konnte nicht hochgeladen werden.'
|
||||
}
|
||||
}
|
||||
|
||||
function clearSocialIcon(index: number) {
|
||||
form.socialLinks[index].icon = ''
|
||||
}
|
||||
|
||||
async function saveSiteSettings(sectionLabel = 'Landingpage-Inhalte') {
|
||||
saving.value = true
|
||||
saveMessage.value = ''
|
||||
saveError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminSiteSettings({
|
||||
hostDisplayName: form.hostDisplayName,
|
||||
hostTagline: form.hostTagline,
|
||||
newsletterUrl: form.newsletterUrl,
|
||||
privacyEmail: form.privacyEmail,
|
||||
privacyPolicyContent: form.privacyPolicyContent,
|
||||
imprintUrl: form.imprintUrl,
|
||||
contactUrl: form.contactUrl,
|
||||
sponsorsUrl: form.sponsorsUrl,
|
||||
socialLinks: form.socialLinks
|
||||
.map((item) => ({
|
||||
label: item.label.trim(),
|
||||
platform: item.platform.trim(),
|
||||
icon: item.icon.trim(),
|
||||
url: item.url.trim(),
|
||||
showOnHost: item.showOnHost,
|
||||
showOnCommunity: item.showOnCommunity,
|
||||
}))
|
||||
.filter((item) => item.label && item.platform && item.url),
|
||||
faq: form.faq
|
||||
.map((item) => ({
|
||||
question: item.question.trim(),
|
||||
answer: item.answer.trim(),
|
||||
}))
|
||||
.filter((item) => item.question && item.answer),
|
||||
})
|
||||
saveMessage.value = `${sectionLabel} gespeichert und auf der Landingpage aktualisiert.`
|
||||
} catch (error) {
|
||||
saveError.value = error instanceof Error ? error.message : 'Inhalte konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
saving,
|
||||
saveMessage,
|
||||
saveError,
|
||||
privacyPreviewOpen,
|
||||
iconUploadError,
|
||||
privacyPreviewBlocks,
|
||||
privacyUpdatedLabel,
|
||||
addSocialLink,
|
||||
removeSocialLink,
|
||||
addFaqItem,
|
||||
removeFaqItem,
|
||||
isUploadedIcon,
|
||||
selectedSocialIconValue,
|
||||
handleSocialIconSelection,
|
||||
hasSocialIconPreview,
|
||||
socialIconModeLabel,
|
||||
socialSimpleIconPath,
|
||||
socialSimpleIconColor,
|
||||
handleSocialIconUpload,
|
||||
clearSocialIcon,
|
||||
saveSiteSettings,
|
||||
adminSiteSettings: computed(() => store.adminSiteSettings),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { computed } from 'vue'
|
||||
import { BarChart3, Clock3, ShieldAlert, Sparkles, Tags, Users } from '@lucide/vue'
|
||||
|
||||
import { getRiskMetricValue, getVoteMetricValue } from '../../lib/adminMetrics'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const metricToneMap = {
|
||||
Nominierungen: {
|
||||
icon: Sparkles,
|
||||
source: 'Nomination-Tabelle',
|
||||
},
|
||||
Stimmen: {
|
||||
icon: BarChart3,
|
||||
source: 'VoteEntries-Tabelle',
|
||||
},
|
||||
Kategorien: {
|
||||
icon: Tags,
|
||||
source: 'Categories-Tabelle',
|
||||
},
|
||||
'Reviews offen': {
|
||||
icon: Clock3,
|
||||
source: 'Nominations-Review-Status',
|
||||
},
|
||||
Risikohinweise: {
|
||||
icon: ShieldAlert,
|
||||
source: 'RiskFlags-Tabelle',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function useAdminDashboardOverview() {
|
||||
const store = useAwardsStore()
|
||||
|
||||
const metrics = computed(() => store.admin.metrics)
|
||||
const activities = computed(() => store.admin.activities)
|
||||
const topCategories = computed(() => store.admin.topCategories)
|
||||
const metricCards = computed(() =>
|
||||
metrics.value.map((metric) => ({
|
||||
...metric,
|
||||
...(metricToneMap[metric.label as keyof typeof metricToneMap] ?? {
|
||||
icon: BarChart3,
|
||||
source: 'Backend-Metrik',
|
||||
}),
|
||||
})),
|
||||
)
|
||||
const openReviewCount = computed(() => store.adminSeasonDetail.pendingNominations.length)
|
||||
const openRiskCount = computed(() => getRiskMetricValue(metrics.value))
|
||||
const liveSummary = computed(() => {
|
||||
const phase = store.adminSeasonDetail.currentPhase || 'nicht gesetzt'
|
||||
const reviewText = openReviewCount.value === 1 ? '1 offene Review' : `${openReviewCount.value} offene Reviews`
|
||||
const riskText = openRiskCount.value === 1 ? '1 Risikohinweis' : `${openRiskCount.value} Risikohinweise`
|
||||
const categoryText = store.adminSeasonDetail.categories.length === 1
|
||||
? '1 Kategorie'
|
||||
: `${store.adminSeasonDetail.categories.length} Kategorien`
|
||||
const candidateText = store.adminSeasonDetail.candidates.length === 1
|
||||
? '1 Kandidat'
|
||||
: `${store.adminSeasonDetail.candidates.length} Kandidaten`
|
||||
|
||||
return `Aktuelle Phase: ${phase}. ${categoryText}, ${candidateText}, ${reviewText} und ${riskText} kommen direkt aus der Admin-API.`
|
||||
})
|
||||
const liveStatusBadge = computed(() => {
|
||||
if (openRiskCount.value > 0) {
|
||||
return `${openRiskCount.value} Risiko offen`
|
||||
}
|
||||
|
||||
if (openReviewCount.value > 0) {
|
||||
return `${openReviewCount.value} Review offen`
|
||||
}
|
||||
|
||||
return 'Betrieb wirkt sauber'
|
||||
})
|
||||
const maxCategoryVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
|
||||
const totalCategoryVotes = computed(() => topCategories.value.reduce((sum, category) => sum + category.votes, 0))
|
||||
const yearTotals = computed(() => [
|
||||
{
|
||||
label: 'Nominierungen gesamt',
|
||||
value: metrics.value.find((metric) => metric.label === 'Nominierungen')?.value ?? 0,
|
||||
note: `im Award-Jahr ${store.adminSeasonDetail.year}`,
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
label: 'Stimmen gesamt',
|
||||
value: getVoteMetricValue(metrics.value),
|
||||
note: 'alle abgegebenen Votes',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
label: 'Kandidaten',
|
||||
value: store.adminSeasonDetail.candidates.length,
|
||||
note: 'für Voting und Archiv gepflegt',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Kategorien',
|
||||
value: store.adminSeasonDetail.categories.length,
|
||||
note: 'aktive Award-Kategorien',
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
label: 'Offene Reviews',
|
||||
value: store.adminSeasonDetail.pendingNominations.length,
|
||||
note: 'brauchen Team-Entscheidung',
|
||||
icon: Clock3,
|
||||
},
|
||||
{
|
||||
label: 'Risikohinweise',
|
||||
value: openRiskCount.value,
|
||||
note: 'aktuell offen',
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
])
|
||||
const priorityActions = computed(() => [
|
||||
{
|
||||
label: 'Reviews bearbeiten',
|
||||
value: store.adminSeasonDetail.pendingNominations.length,
|
||||
to: '/admin/nominations?review=1',
|
||||
hint: 'Freitext-Nominierungen warten auf Entscheidung',
|
||||
icon: Sparkles,
|
||||
tone: 'violet',
|
||||
},
|
||||
{
|
||||
label: 'Risiko prüfen',
|
||||
value: openRiskCount.value,
|
||||
to: '/admin/risk',
|
||||
hint: 'Auffällige Muster brauchen Sichtung',
|
||||
icon: ShieldAlert,
|
||||
tone: 'rose',
|
||||
},
|
||||
{
|
||||
label: 'Kategorien pflegen',
|
||||
value: store.adminSeasonDetail.categories.length,
|
||||
to: '/admin/categories',
|
||||
hint: 'Texte, Limits und Reihenfolge aktuell halten',
|
||||
icon: Tags,
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
label: 'Kandidatenbasis',
|
||||
value: store.adminSeasonDetail.candidates.length,
|
||||
to: '/admin/candidates',
|
||||
hint: 'Kandidaten und Plattformen schnell prüfen',
|
||||
icon: Users,
|
||||
tone: 'emerald',
|
||||
},
|
||||
])
|
||||
const operationChecks = computed(() => {
|
||||
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
|
||||
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
|
||||
)
|
||||
const categoriesWithReviews = store.adminSeasonDetail.categories.filter((category) =>
|
||||
store.adminSeasonDetail.pendingNominations.some((nomination) => nomination.categoryId === category.id),
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Kategorien ohne Kandidaten',
|
||||
value: categoriesWithoutCandidates.length,
|
||||
to: '/admin/categories',
|
||||
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
|
||||
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt prüfen.',
|
||||
},
|
||||
{
|
||||
label: 'Review-Backlog verteilt',
|
||||
value: categoriesWithReviews.length,
|
||||
to: '/admin/nominations',
|
||||
state: categoriesWithReviews.length <= 1 ? 'ok' : 'warn',
|
||||
note: categoriesWithReviews.length <= 1 ? 'Backlog ist fokussiert.' : 'Mehrere Kategorien brauchen Sichtung.',
|
||||
},
|
||||
{
|
||||
label: 'Risk Flags offen',
|
||||
value: openRiskCount.value,
|
||||
to: '/admin/risk',
|
||||
state: openRiskCount.value === 0 ? 'ok' : 'danger',
|
||||
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
store,
|
||||
activities,
|
||||
topCategories,
|
||||
metricCards,
|
||||
openReviewCount,
|
||||
openRiskCount,
|
||||
liveSummary,
|
||||
liveStatusBadge,
|
||||
maxCategoryVotes,
|
||||
totalCategoryVotes,
|
||||
yearTotals,
|
||||
priorityActions,
|
||||
operationChecks,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { ApiRequestError, api } from '../../lib/api'
|
||||
import { clearSiteStatusCache } from '../../lib/siteStatus'
|
||||
import type { AdminOperationalSettingsResponse } from '../../types/awards'
|
||||
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary } from './adminSettingsTypes'
|
||||
|
||||
const fallbackMaintenanceTitle = 'Sternenpause'
|
||||
const fallbackMaintenanceMessage = 'Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.'
|
||||
|
||||
export function useAdminOperationalSettings() {
|
||||
const operationalLoading = ref(true)
|
||||
const operationalSaving = ref(false)
|
||||
const operationalError = ref('')
|
||||
const operationalSuccess = ref('')
|
||||
const demoPasswordSet = ref(false)
|
||||
const demoManagedByDatabase = ref(false)
|
||||
const demoPasswordInput = ref('')
|
||||
const savedOperationalSnapshot = ref('')
|
||||
|
||||
const operationalForm = reactive<AdminOperationalSettingsForm>({
|
||||
demoLoginEnabled: false,
|
||||
demoLoginIdentifier: '',
|
||||
demoLoginTwitchUserId: '',
|
||||
demoLoginDisplayName: '',
|
||||
maintenanceModeEnabled: false,
|
||||
maintenanceTitle: fallbackMaintenanceTitle,
|
||||
maintenanceMessage: fallbackMaintenanceMessage,
|
||||
})
|
||||
|
||||
const demoPasswordHint = computed(() => {
|
||||
if (demoPasswordInput.value.trim()) {
|
||||
return 'Dieses neue Passwort wird beim Speichern gesetzt.'
|
||||
}
|
||||
|
||||
if (demoPasswordSet.value) {
|
||||
return demoManagedByDatabase.value
|
||||
? 'Passwort ist gesetzt. Leer lassen, wenn es bleiben soll.'
|
||||
: 'Aktuell kommt das Passwort aus der Demo-Config. Beim Speichern wird es in die DB übernommen.'
|
||||
}
|
||||
|
||||
return 'Noch kein Demo-Passwort gesetzt. Zum Aktivieren bitte ein Passwort vergeben.'
|
||||
})
|
||||
|
||||
const demoCredentialsComplete = computed(() =>
|
||||
!operationalForm.demoLoginEnabled
|
||||
|| (
|
||||
Boolean(operationalForm.demoLoginIdentifier.trim())
|
||||
&& Boolean(operationalForm.demoLoginTwitchUserId.trim())
|
||||
&& Boolean(operationalForm.demoLoginDisplayName.trim())
|
||||
&& (demoPasswordSet.value || Boolean(demoPasswordInput.value.trim()))
|
||||
),
|
||||
)
|
||||
|
||||
const operationalSummary = computed<AdminSettingsStatusSummary[]>(() => [
|
||||
{
|
||||
label: 'Demo Login',
|
||||
value: operationalForm.demoLoginEnabled ? 'Aktiv' : 'Aus',
|
||||
note: operationalForm.demoLoginEnabled
|
||||
? demoCredentialsComplete.value
|
||||
? 'Öffentlicher Zugriff ist geschützt.'
|
||||
: 'Aktivierung braucht vollständige Zugangsdaten.'
|
||||
: 'Landingpage ist öffentlich erreichbar.',
|
||||
tone: operationalForm.demoLoginEnabled
|
||||
? demoCredentialsComplete.value ? 'warning' : 'danger'
|
||||
: 'good',
|
||||
},
|
||||
{
|
||||
label: 'Wartungsmodus',
|
||||
value: operationalForm.maintenanceModeEnabled ? 'Aktiv' : 'Aus',
|
||||
note: operationalForm.maintenanceModeEnabled
|
||||
? 'Besucher sehen die Wartungsseite.'
|
||||
: 'Öffentliche Seiten werden normal ausgeliefert.',
|
||||
tone: operationalForm.maintenanceModeEnabled ? 'warning' : 'good',
|
||||
},
|
||||
{
|
||||
label: 'Passwort',
|
||||
value: demoPasswordSet.value ? 'Gesetzt' : 'Fehlt',
|
||||
note: demoManagedByDatabase.value ? 'Quelle: Datenbank' : 'Quelle: App-Konfiguration',
|
||||
tone: demoPasswordSet.value ? 'good' : 'warning',
|
||||
},
|
||||
])
|
||||
|
||||
const hasUnsavedOperationalChanges = computed(() =>
|
||||
Boolean(savedOperationalSnapshot.value) &&
|
||||
buildOperationalSnapshot() !== savedOperationalSnapshot.value,
|
||||
)
|
||||
|
||||
function applyOperationalSettings(response: AdminOperationalSettingsResponse) {
|
||||
operationalForm.demoLoginEnabled = response.demoLoginEnabled
|
||||
operationalForm.demoLoginIdentifier = response.demoLoginEmail
|
||||
operationalForm.demoLoginTwitchUserId = response.demoLoginTwitchUserId
|
||||
operationalForm.demoLoginDisplayName = response.demoLoginDisplayName
|
||||
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
|
||||
operationalForm.maintenanceTitle = response.maintenanceTitle
|
||||
operationalForm.maintenanceMessage = response.maintenanceMessage
|
||||
demoPasswordSet.value = response.demoLoginPasswordSet
|
||||
demoManagedByDatabase.value = response.demoLoginManagedByDatabase
|
||||
demoPasswordInput.value = ''
|
||||
rememberSavedOperationalSettings()
|
||||
}
|
||||
|
||||
function buildOperationalSnapshot() {
|
||||
return JSON.stringify({
|
||||
demoLoginEnabled: operationalForm.demoLoginEnabled,
|
||||
demoLoginIdentifier: operationalForm.demoLoginIdentifier,
|
||||
demoLoginTwitchUserId: operationalForm.demoLoginTwitchUserId,
|
||||
demoLoginDisplayName: operationalForm.demoLoginDisplayName,
|
||||
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
|
||||
maintenanceTitle: operationalForm.maintenanceTitle,
|
||||
maintenanceMessage: operationalForm.maintenanceMessage,
|
||||
demoPasswordInput: demoPasswordInput.value,
|
||||
})
|
||||
}
|
||||
|
||||
function rememberSavedOperationalSettings() {
|
||||
savedOperationalSnapshot.value = buildOperationalSnapshot()
|
||||
}
|
||||
|
||||
async function loadOperationalSettings(options: { silent?: boolean } = {}) {
|
||||
if (!options.silent) {
|
||||
operationalLoading.value = true
|
||||
operationalError.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
applyOperationalSettings(await api.getAdminOperationalSettings())
|
||||
} catch (error) {
|
||||
operationalError.value = error instanceof ApiRequestError
|
||||
? error.message
|
||||
: 'Operational Settings konnten nicht geladen werden.'
|
||||
} finally {
|
||||
if (!options.silent) {
|
||||
operationalLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateOperationalSettings() {
|
||||
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 12) {
|
||||
operationalError.value = 'Das Demo-Passwort muss mindestens 12 Zeichen lang sein.'
|
||||
return false
|
||||
}
|
||||
|
||||
if (operationalForm.demoLoginEnabled && !demoCredentialsComplete.value) {
|
||||
operationalError.value = 'Demo-Login braucht Login, Twitch-ID, Anzeigenamen und ein gesetztes Passwort.'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveOperationalSettings() {
|
||||
operationalError.value = ''
|
||||
operationalSuccess.value = ''
|
||||
|
||||
if (!validateOperationalSettings()) {
|
||||
return
|
||||
}
|
||||
|
||||
operationalSaving.value = true
|
||||
try {
|
||||
const result = await api.updateAdminOperationalSettings({
|
||||
...operationalForm,
|
||||
demoLoginEmail: operationalForm.demoLoginIdentifier,
|
||||
demoLoginPassword: demoPasswordInput.value.trim() || undefined,
|
||||
})
|
||||
|
||||
demoPasswordSet.value = result.demoLoginPasswordSet
|
||||
demoManagedByDatabase.value = true
|
||||
demoPasswordInput.value = ''
|
||||
await loadOperationalSettings({ silent: true })
|
||||
clearSiteStatusCache()
|
||||
operationalSuccess.value = 'Demo-Zugang und Wartungsmodus wurden gespeichert.'
|
||||
} catch (error) {
|
||||
operationalError.value = error instanceof ApiRequestError
|
||||
? error.message
|
||||
: 'Operational Settings konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
operationalSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
operationalForm.demoLoginEnabled,
|
||||
operationalForm.demoLoginIdentifier,
|
||||
operationalForm.demoLoginTwitchUserId,
|
||||
operationalForm.demoLoginDisplayName,
|
||||
operationalForm.maintenanceModeEnabled,
|
||||
operationalForm.maintenanceTitle,
|
||||
operationalForm.maintenanceMessage,
|
||||
demoPasswordInput.value,
|
||||
],
|
||||
() => {
|
||||
if (operationalLoading.value || operationalSaving.value) return
|
||||
operationalError.value = ''
|
||||
operationalSuccess.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(loadOperationalSettings)
|
||||
|
||||
return {
|
||||
operationalLoading,
|
||||
operationalSaving,
|
||||
operationalError,
|
||||
operationalSuccess,
|
||||
operationalForm,
|
||||
demoPasswordSet,
|
||||
demoManagedByDatabase,
|
||||
demoPasswordInput,
|
||||
demoPasswordHint,
|
||||
demoCredentialsComplete,
|
||||
operationalSummary,
|
||||
hasUnsavedOperationalChanges,
|
||||
loadOperationalSettings,
|
||||
saveOperationalSettings,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
export function useAdminReviewsManager() {
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
const reviewSaving = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const reviewFilter = ref('')
|
||||
const categoryFilter = ref<number | null>(null)
|
||||
const selectedNominationId = ref<number | null>(null)
|
||||
const reviewForms = reactive<Record<number, {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
reviewNote: string
|
||||
}>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
|
||||
const filteredNominations = computed(() => {
|
||||
const query = reviewFilter.value.trim().toLowerCase()
|
||||
return seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
|
||||
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query)),
|
||||
)
|
||||
})
|
||||
const selectedNomination = computed(() =>
|
||||
filteredNominations.value.find((nomination) => nomination.id === selectedNominationId.value) ?? filteredNominations.value[0] ?? null,
|
||||
)
|
||||
const reviewStats = computed(() => [
|
||||
{ label: 'Offen', value: seasonDetail.value.pendingNominations.length },
|
||||
{ label: 'Entschieden', value: seasonDetail.value.reviewedNominations.length },
|
||||
{ label: 'Sichtbar', value: filteredNominations.value.length },
|
||||
{ label: 'Kategorien', value: new Set(seasonDetail.value.pendingNominations.map((nomination) => nomination.categoryName)).size },
|
||||
])
|
||||
const reviewedNominations = computed(() => seasonDetail.value.reviewedNominations.slice(0, 12))
|
||||
const categoryOptions = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.filter((category) => seasonDetail.value.pendingNominations.some((nomination) => nomination.categoryId === category.id))
|
||||
.map((category) => ({
|
||||
id: category.id,
|
||||
label: category.name,
|
||||
count: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||
})),
|
||||
)
|
||||
const selectedCandidateCollision = computed(() => {
|
||||
if (!selectedNomination.value) return null
|
||||
const form = reviewForms[selectedNomination.value.id]
|
||||
if (!form) return null
|
||||
const normalizedName = form.displayName.trim().toLowerCase()
|
||||
const normalizedSlug = form.channelSlug.trim().toLowerCase()
|
||||
return seasonDetail.value.candidates.find((candidate) =>
|
||||
candidate.categoryId === selectedNomination.value?.categoryId &&
|
||||
(candidate.displayName.trim().toLowerCase() === normalizedName || (!!normalizedSlug && candidate.channelSlug.trim().toLowerCase() === normalizedSlug)),
|
||||
) ?? null
|
||||
})
|
||||
const selectedRelatedPendingNominations = computed(() => {
|
||||
if (!selectedNomination.value) return []
|
||||
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
|
||||
const selectedStreamUrl = extractNominationStreamUrl(selectedNomination.value).toLowerCase()
|
||||
|
||||
return seasonDetail.value.pendingNominations
|
||||
.filter((nomination) => {
|
||||
if (nomination.id === selectedNomination.value?.id || nomination.categoryId !== selectedNomination.value?.categoryId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sameName = nomination.candidateText.trim().toLowerCase() === selectedName
|
||||
const sameStreamUrl = selectedStreamUrl && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl
|
||||
return sameName || sameStreamUrl
|
||||
})
|
||||
.slice(0, 5)
|
||||
})
|
||||
const selectedNominationSignalSummary = computed(() => {
|
||||
if (!selectedNomination.value) return null
|
||||
|
||||
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
|
||||
const relatedNominations = seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||
nomination.categoryId === selectedNomination.value?.categoryId &&
|
||||
nomination.candidateText.trim().toLowerCase() === selectedName,
|
||||
)
|
||||
const submitters = new Set(relatedNominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean))
|
||||
const platforms = new Set(
|
||||
relatedNominations
|
||||
.map((nomination) => resolveStreamIdentity(extractNominationStreamUrl(nomination)).platform)
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
return {
|
||||
submissions: relatedNominations.length,
|
||||
uniqueSubmitters: submitters.size,
|
||||
platforms: [...platforms].sort(),
|
||||
}
|
||||
})
|
||||
const canApproveSelected = computed(() => {
|
||||
if (!selectedNomination.value) return false
|
||||
const form = reviewForms[selectedNomination.value.id]
|
||||
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim())
|
||||
})
|
||||
|
||||
function focusNominationFromRoute() {
|
||||
const rawNominationId = Array.isArray(route.query.nominationId) ? route.query.nominationId[0] : route.query.nominationId
|
||||
const nominationId = Number(rawNominationId)
|
||||
if (!Number.isFinite(nominationId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nomination = seasonDetail.value.pendingNominations.find((item) => item.id === nominationId)
|
||||
if (!nomination) {
|
||||
return false
|
||||
}
|
||||
|
||||
reviewFilter.value = ''
|
||||
categoryFilter.value = nomination.categoryId
|
||||
selectedNominationId.value = nomination.id
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
for (const nomination of detail.pendingNominations) {
|
||||
const streamIdentity = resolveStreamIdentity(extractNominationStreamUrl(nomination))
|
||||
reviewForms[nomination.id] = {
|
||||
displayName: nomination.candidateText,
|
||||
channelSlug: streamIdentity.channelSlug,
|
||||
platform: streamIdentity.platform,
|
||||
reviewNote: nomination.reviewNote ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
if (focusNominationFromRoute()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!detail.pendingNominations.some((nomination) => nomination.id === selectedNominationId.value)) {
|
||||
selectedNominationId.value = detail.pendingNominations[0]?.id ?? null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.nominationId,
|
||||
() => {
|
||||
focusNominationFromRoute()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
filteredNominations,
|
||||
(nominations) => {
|
||||
if (!nominations.some((nomination) => nomination.id === selectedNominationId.value)) {
|
||||
selectedNominationId.value = nominations[0]?.id ?? null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function approveNomination(nominationId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
reviewSaving.value = nominationId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
|
||||
adminMessage.value = 'Nominierung wurde in die Kandidatenliste übernommen.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht übernommen werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectNomination(nominationId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
reviewSaving.value = nominationId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.rejectAdminNomination(nominationId, selectedSeasonId.value, {
|
||||
reviewNote: reviewForms[nominationId]?.reviewNote || undefined,
|
||||
})
|
||||
adminMessage.value = 'Nominierung wurde aus der Review-Liste entfernt.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function selectedPlatformValue(platform: string) {
|
||||
return socialIconOptionForValue(platform)?.key ?? 'custom'
|
||||
}
|
||||
|
||||
function setPlatform(platform: string) {
|
||||
if (!selectedNomination.value) return
|
||||
const form = reviewForms[selectedNomination.value.id]
|
||||
if (!form) return
|
||||
|
||||
if (platform === 'custom') {
|
||||
if (socialIconOptionForValue(form.platform)) {
|
||||
form.platform = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
form.platform = socialIconOptionForValue(platform)?.label ?? platform
|
||||
}
|
||||
|
||||
function handlePlatformSelection(event: Event) {
|
||||
setPlatform((event.target as HTMLSelectElement).value)
|
||||
}
|
||||
|
||||
return {
|
||||
reviewSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
reviewForms,
|
||||
seasonDetail,
|
||||
reviewFilter,
|
||||
categoryFilter,
|
||||
selectedNominationId,
|
||||
candidatePlatformOptions,
|
||||
filteredNominations,
|
||||
selectedNomination,
|
||||
reviewStats,
|
||||
reviewedNominations,
|
||||
categoryOptions,
|
||||
selectedCandidateCollision,
|
||||
selectedRelatedPendingNominations,
|
||||
selectedNominationSignalSummary,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
selectedPlatformValue,
|
||||
handlePlatformSelection,
|
||||
extractNominationStreamUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function extractNominationStreamUrl(nomination: { streamUrl?: string | null; reviewNote?: string | null }) {
|
||||
if (nomination.streamUrl?.trim()) {
|
||||
return nomination.streamUrl.trim()
|
||||
}
|
||||
|
||||
const noteMatch = nomination.reviewNote?.match(/Stream-Link:\s*(https?:\/\/\S+)/i)
|
||||
return noteMatch?.[1]?.trim() ?? ''
|
||||
}
|
||||
|
||||
function resolveStreamIdentity(streamUrl: string) {
|
||||
const fallback = { platform: 'Twitch', channelSlug: '' }
|
||||
if (!streamUrl) return fallback
|
||||
|
||||
try {
|
||||
const url = new URL(streamUrl)
|
||||
const host = url.hostname.replace(/^www\./, '').toLowerCase()
|
||||
const firstPathPart = url.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.find((part) => !['c', 'channel', 'user', 'live'].includes(part.toLowerCase()))
|
||||
?.replace(/^@/, '')
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '') ?? ''
|
||||
|
||||
if (host.includes('twitch.tv')) {
|
||||
return { platform: 'Twitch', channelSlug: firstPathPart }
|
||||
}
|
||||
|
||||
if (host.includes('kick.com')) {
|
||||
return { platform: 'Kick', channelSlug: firstPathPart }
|
||||
}
|
||||
|
||||
if (host.includes('youtube.com') || host.includes('youtu.be')) {
|
||||
return { platform: 'YouTube', channelSlug: firstPathPart }
|
||||
}
|
||||
|
||||
const platform = host.split('.')[0]
|
||||
return {
|
||||
platform: platform ? `${platform.charAt(0).toUpperCase()}${platform.slice(1)}` : fallback.platform,
|
||||
channelSlug: firstPathPart,
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminRiskFlag, AdminRiskRule } from '../../types/awards'
|
||||
|
||||
export type RiskResolutionStatus = 'open' | 'resolved' | 'dismissed'
|
||||
|
||||
export interface RiskMetadataItem {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type SeverityFilter = 'all' | 'high' | 'medium' | 'low'
|
||||
type HistoryStatusFilter = 'all' | 'resolved' | 'dismissed'
|
||||
const QUEUE_PAGE_SIZE = 12
|
||||
const HISTORY_PAGE_SIZE = 10
|
||||
|
||||
export function riskSeverityClass(severity: string) {
|
||||
const normalized = severity.toLowerCase()
|
||||
if (normalized === 'high') return 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
if (normalized === 'medium') return 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
if (normalized === 'low') return 'border-sky-200 bg-sky-50 text-sky-700'
|
||||
return 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
}
|
||||
|
||||
export function riskStatusLabel(status: string) {
|
||||
if (status === 'resolved') return 'Erledigt'
|
||||
if (status === 'dismissed') return 'Verworfen'
|
||||
if (status === 'open') return 'Offen'
|
||||
return status
|
||||
}
|
||||
|
||||
export function riskAgeLabel(flag: AdminRiskFlag) {
|
||||
const createdAt = new Date(flag.createdAt)
|
||||
if (Number.isNaN(createdAt.getTime())) return 'unbekannt'
|
||||
|
||||
const diffMinutes = Math.max(0, Math.round((Date.now() - createdAt.getTime()) / 60000))
|
||||
if (diffMinutes < 60) return `${diffMinutes} min`
|
||||
|
||||
const diffHours = Math.round(diffMinutes / 60)
|
||||
if (diffHours < 48) return `${diffHours} Std.`
|
||||
|
||||
return `${Math.round(diffHours / 24)} Tage`
|
||||
}
|
||||
|
||||
export function useAdminRiskManager() {
|
||||
const store = useAwardsStore()
|
||||
const riskSaving = ref<number | null>(null)
|
||||
const riskLoading = ref(false)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const riskFilter = ref('')
|
||||
const severityFilter = ref<SeverityFilter>('all')
|
||||
const historyStatusFilter = ref<HistoryStatusFilter>('all')
|
||||
const selectedRiskFlagId = ref<number | null>(null)
|
||||
const selectedDecisionNote = ref('')
|
||||
const queuePage = ref(1)
|
||||
const historyPage = ref(1)
|
||||
const selectedBulkRiskFlagIds = ref<number[]>([])
|
||||
const bulkReviewNote = ref('')
|
||||
const bulkSaving = ref(false)
|
||||
const riskRules = ref<AdminRiskRule[]>([])
|
||||
const riskRulesLoading = ref(false)
|
||||
const riskRulesSaving = ref(false)
|
||||
const queuePageSize = QUEUE_PAGE_SIZE
|
||||
const historyPageSize = HISTORY_PAGE_SIZE
|
||||
|
||||
const riskFlags = computed(() => store.admin.riskFlags)
|
||||
const riskHistory = computed(() => store.adminRiskHistory)
|
||||
const riskLoadedLabel = computed(() => {
|
||||
const total = store.adminRiskFlagsPage.totalCount
|
||||
return total > 0 ? createPageLabel(store.adminRiskFlagsPage.offset, store.adminRiskFlagsPage.returnedCount, total) : 'keine offenen Hinweise'
|
||||
})
|
||||
const queuePageLabel = computed(() =>
|
||||
createPageLabel(store.adminRiskFlagsPage.offset, store.adminRiskFlagsPage.returnedCount, store.adminRiskFlagsPage.totalCount),
|
||||
)
|
||||
const queueHasPrevious = computed(() => queuePage.value > 1)
|
||||
const queueHasMore = computed(() => store.adminRiskFlagsPage.hasMore)
|
||||
const historyPageLabel = computed(() =>
|
||||
createPageLabel(store.adminRiskHistoryPage.offset, store.adminRiskHistoryPage.returnedCount, store.adminRiskHistoryPage.totalCount),
|
||||
)
|
||||
const historyHasPrevious = computed(() => historyPage.value > 1)
|
||||
const historyHasMore = computed(() => store.adminRiskHistoryPage.hasMore)
|
||||
|
||||
const filteredRiskFlags = computed(() => riskFlags.value)
|
||||
const selectedRiskFlag = computed(() =>
|
||||
filteredRiskFlags.value.find((flag) => flag.id === selectedRiskFlagId.value) ?? filteredRiskFlags.value[0] ?? null,
|
||||
)
|
||||
const selectedRiskMetadata = computed<RiskMetadataItem[]>(() => parseRiskMetadata(selectedRiskFlag.value?.metadataJson))
|
||||
const selectedDecisionNoteLength = computed(() => selectedDecisionNote.value.trim().length)
|
||||
const selectedDecisionReady = computed(() => selectedDecisionNoteLength.value >= 3 && selectedDecisionNoteLength.value <= 500)
|
||||
const canBulkResolve = computed(() =>
|
||||
selectedBulkRiskFlagIds.value.length > 0 &&
|
||||
bulkReviewNote.value.trim().length >= 3 &&
|
||||
bulkReviewNote.value.trim().length <= 500,
|
||||
)
|
||||
|
||||
const riskStats = computed(() => {
|
||||
const highCount = countFor(store.adminRiskFlagsPage.severityCounts, 'high')
|
||||
const uniqueUsers = new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size
|
||||
return [
|
||||
{ label: 'Offen', value: store.adminRiskFlagsPage.totalCount, tone: store.adminRiskFlagsPage.totalCount > 0 ? 'warning' : 'ok' },
|
||||
{ label: 'High', value: highCount, tone: highCount > 0 ? 'danger' : 'ok' },
|
||||
{ label: 'Medium', value: countFor(store.adminRiskFlagsPage.severityCounts, 'medium'), tone: 'warning' },
|
||||
{ label: 'User betroffen', value: uniqueUsers, tone: 'neutral' },
|
||||
]
|
||||
})
|
||||
const severityFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: store.adminRiskFlagsPage.totalCount },
|
||||
{ key: 'high' as const, label: 'High', count: countFor(store.adminRiskFlagsPage.severityCounts, 'high') },
|
||||
{ key: 'medium' as const, label: 'Medium', count: countFor(store.adminRiskFlagsPage.severityCounts, 'medium') },
|
||||
{ key: 'low' as const, label: 'Low', count: countFor(store.adminRiskFlagsPage.severityCounts, 'low') },
|
||||
])
|
||||
|
||||
const recentRiskHistory = computed(() => riskHistory.value)
|
||||
const riskHistoryStats = computed(() => [
|
||||
{ label: 'Entschieden', value: store.adminRiskHistoryPage.totalCount },
|
||||
{ label: 'Erledigt', value: countFor(store.adminRiskHistoryPage.statusCounts, 'resolved') },
|
||||
{ label: 'Verworfen', value: countFor(store.adminRiskHistoryPage.statusCounts, 'dismissed') },
|
||||
])
|
||||
const historyStatusFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: store.adminRiskHistoryPage.totalCount },
|
||||
{ key: 'resolved' as const, label: 'Erledigt', count: countFor(store.adminRiskHistoryPage.statusCounts, 'resolved') },
|
||||
{ key: 'dismissed' as const, label: 'Verworfen', count: countFor(store.adminRiskHistoryPage.statusCounts, 'dismissed') },
|
||||
])
|
||||
|
||||
async function loadRiskFlags() {
|
||||
riskLoading.value = true
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
const query = riskFilter.value.trim()
|
||||
await Promise.all([
|
||||
store.loadAdminRiskFlagsPage({
|
||||
limit: queuePageSize,
|
||||
offset: (queuePage.value - 1) * queuePageSize,
|
||||
status: 'open',
|
||||
severity: severityFilter.value === 'all' ? undefined : severityFilter.value,
|
||||
query,
|
||||
}),
|
||||
store.loadAdminRiskHistoryPage({
|
||||
limit: historyPageSize,
|
||||
offset: (historyPage.value - 1) * historyPageSize,
|
||||
status: historyStatusFilter.value,
|
||||
reviewedOnly: true,
|
||||
query,
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Risikohinweise konnten nicht geladen werden.'
|
||||
} finally {
|
||||
riskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setQueuePage(page: number) {
|
||||
queuePage.value = Math.max(1, page)
|
||||
selectedBulkRiskFlagIds.value = []
|
||||
await loadRiskFlags()
|
||||
}
|
||||
|
||||
async function setHistoryPage(page: number) {
|
||||
historyPage.value = Math.max(1, page)
|
||||
await loadRiskFlags()
|
||||
}
|
||||
|
||||
async function updateRiskFlagStatus(riskFlagId: number, status: RiskResolutionStatus) {
|
||||
if (riskSaving.value) return
|
||||
if (status !== 'open' && !selectedDecisionReady.value) {
|
||||
adminError.value = 'Bitte notiere kurz, warum du diesen Hinweis entscheidest.'
|
||||
return
|
||||
}
|
||||
|
||||
riskSaving.value = riskFlagId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
const reviewNote = status === 'open' ? 'Wieder geöffnet.' : selectedDecisionNote.value.trim()
|
||||
await store.resolveRiskFlag(riskFlagId, { status, reviewNote })
|
||||
selectedDecisionNote.value = ''
|
||||
await loadRiskFlags()
|
||||
adminMessage.value = `Risikohinweis ${riskFlagId} wurde aktualisiert.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Risikohinweis konnte nicht aktualisiert werden.'
|
||||
} finally {
|
||||
riskSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBulkRiskFlag(riskFlagId: number) {
|
||||
const flag = filteredRiskFlags.value.find((item) => item.id === riskFlagId)
|
||||
if (!flag || flag.severity.toLowerCase() !== 'low') return
|
||||
|
||||
selectedBulkRiskFlagIds.value = selectedBulkRiskFlagIds.value.includes(riskFlagId)
|
||||
? selectedBulkRiskFlagIds.value.filter((id) => id !== riskFlagId)
|
||||
: [...selectedBulkRiskFlagIds.value, riskFlagId]
|
||||
}
|
||||
|
||||
function selectVisibleLowRiskFlags() {
|
||||
selectedBulkRiskFlagIds.value = filteredRiskFlags.value
|
||||
.filter((flag) => flag.severity.toLowerCase() === 'low')
|
||||
.map((flag) => flag.id)
|
||||
}
|
||||
|
||||
function clearBulkSelection() {
|
||||
selectedBulkRiskFlagIds.value = []
|
||||
bulkReviewNote.value = ''
|
||||
}
|
||||
|
||||
async function bulkResolveRiskFlags(status: Exclude<RiskResolutionStatus, 'open'>) {
|
||||
if (!canBulkResolve.value || bulkSaving.value) return
|
||||
|
||||
bulkSaving.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.bulkResolveRiskFlags({
|
||||
riskFlagIds: selectedBulkRiskFlagIds.value,
|
||||
status,
|
||||
reviewNote: bulkReviewNote.value.trim(),
|
||||
})
|
||||
adminMessage.value = `${selectedBulkRiskFlagIds.value.length} Risikohinweise wurden aktualisiert.`
|
||||
clearBulkSelection()
|
||||
await loadRiskFlags()
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Bulk-Entscheidung konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
bulkSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateRiskRule(ruleKey: string, patch: Partial<AdminRiskRule>) {
|
||||
riskRules.value = riskRules.value.map((rule) =>
|
||||
rule.key === ruleKey ? { ...rule, ...patch } : rule,
|
||||
)
|
||||
}
|
||||
|
||||
async function saveRiskRules() {
|
||||
riskRulesSaving.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
const response = await store.updateAdminRiskRules({ rules: riskRules.value })
|
||||
riskRules.value = response.rules
|
||||
adminMessage.value = 'Risk-Regeln wurden gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Risk-Regeln konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
riskRulesSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRiskRules() {
|
||||
riskRulesLoading.value = true
|
||||
try {
|
||||
const response = await store.loadAdminRiskRules()
|
||||
riskRules.value = response.rules
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Risk-Regeln konnten nicht geladen werden.'
|
||||
} finally {
|
||||
riskRulesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(filteredRiskFlags, (flags) => {
|
||||
if (!flags.some((flag) => flag.id === selectedRiskFlagId.value)) {
|
||||
selectedRiskFlagId.value = flags[0]?.id ?? null
|
||||
}
|
||||
}, { immediate: true })
|
||||
watch(selectedRiskFlag, (flag) => {
|
||||
selectedDecisionNote.value = flag?.reviewNote ?? ''
|
||||
})
|
||||
let filterReloadTimer: number | undefined
|
||||
watch([riskFilter, severityFilter, historyStatusFilter], () => {
|
||||
queuePage.value = 1
|
||||
historyPage.value = 1
|
||||
window.clearTimeout(filterReloadTimer)
|
||||
filterReloadTimer = window.setTimeout(() => {
|
||||
void loadRiskFlags()
|
||||
}, 250)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadRiskFlags()
|
||||
void loadRiskRules()
|
||||
})
|
||||
|
||||
return {
|
||||
riskSaving,
|
||||
riskLoading,
|
||||
adminMessage,
|
||||
adminError,
|
||||
riskFilter,
|
||||
severityFilter,
|
||||
historyStatusFilter,
|
||||
selectedRiskFlagId,
|
||||
selectedDecisionNote,
|
||||
selectedDecisionNoteLength,
|
||||
selectedDecisionReady,
|
||||
queuePage,
|
||||
queuePageLabel,
|
||||
queueHasPrevious,
|
||||
queueHasMore,
|
||||
historyPage,
|
||||
historyPageLabel,
|
||||
historyHasPrevious,
|
||||
historyHasMore,
|
||||
selectedBulkRiskFlagIds,
|
||||
bulkReviewNote,
|
||||
bulkSaving,
|
||||
canBulkResolve,
|
||||
riskRules,
|
||||
riskRulesLoading,
|
||||
riskRulesSaving,
|
||||
riskFlags,
|
||||
riskHistory,
|
||||
filteredRiskFlags,
|
||||
selectedRiskFlag,
|
||||
selectedRiskMetadata,
|
||||
riskStats,
|
||||
riskHistoryStats,
|
||||
recentRiskHistory,
|
||||
severityFilters,
|
||||
historyStatusFilters,
|
||||
riskLoadedLabel,
|
||||
loadRiskFlags,
|
||||
updateRiskFlagStatus,
|
||||
setQueuePage,
|
||||
setHistoryPage,
|
||||
toggleBulkRiskFlag,
|
||||
selectVisibleLowRiskFlags,
|
||||
clearBulkSelection,
|
||||
bulkResolveRiskFlags,
|
||||
updateRiskRule,
|
||||
saveRiskRules,
|
||||
}
|
||||
}
|
||||
|
||||
function countFor(counts: Array<{ key: string; count: number }>, key: string) {
|
||||
return counts.find((item) => item.key === key)?.count ?? 0
|
||||
}
|
||||
|
||||
function createPageLabel(offset: number, returnedCount: number, totalCount: number) {
|
||||
if (totalCount === 0) return '0 Treffer'
|
||||
|
||||
const start = offset + 1
|
||||
const end = offset + returnedCount
|
||||
return `${start}-${end} / ${totalCount}`
|
||||
}
|
||||
|
||||
function parseRiskMetadata(metadataJson: string | undefined) {
|
||||
if (!metadataJson) return []
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(metadataJson) as Record<string, unknown>
|
||||
return Object.entries(parsed)
|
||||
.filter(([key]) => key !== 'entityLinks')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'string' ? value : JSON.stringify(value),
|
||||
}))
|
||||
} catch {
|
||||
return [{ key: 'raw', value: metadataJson }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { api } from '../../lib/api'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminAuditEntry, AdminSeasonDetailResponse } from '../../types/awards'
|
||||
import { hasShowDayPassed, normalizePhaseKey } from './adminSeasonTimeline'
|
||||
import type {
|
||||
AdminSeasonCopyOption,
|
||||
AdminSeasonCreateForm,
|
||||
AdminSeasonForm,
|
||||
AdminSeasonReadinessItem,
|
||||
} from './adminSeasonTypes'
|
||||
|
||||
function createEmptySeasonForm(): AdminSeasonForm {
|
||||
return {
|
||||
year: new Date().getFullYear(),
|
||||
name: '',
|
||||
showStreamUrl: '',
|
||||
currentPhase: '',
|
||||
isCurrent: false,
|
||||
isCommunityOnly: true,
|
||||
nominationStartsAt: '',
|
||||
nominationEndsAt: '',
|
||||
votingStartsAt: '',
|
||||
votingEndsAt: '',
|
||||
reviewStartsAt: '',
|
||||
reviewEndsAt: '',
|
||||
showDate: '',
|
||||
showStartsAt: '20:00',
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyCreateForm(): AdminSeasonCreateForm {
|
||||
return {
|
||||
year: new Date().getFullYear() + 1,
|
||||
name: '',
|
||||
showStreamUrl: 'https://twitch.tv/jayuhime',
|
||||
currentPhase: 'Nominierung',
|
||||
isCurrent: false,
|
||||
isCommunityOnly: true,
|
||||
nominationStartsAt: '',
|
||||
nominationEndsAt: '',
|
||||
votingStartsAt: '',
|
||||
votingEndsAt: '',
|
||||
reviewStartsAt: '',
|
||||
reviewEndsAt: '',
|
||||
showDate: '',
|
||||
showStartsAt: '20:00',
|
||||
copyStructureFromSeasonId: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAdminSeasonManager() {
|
||||
const store = useAwardsStore()
|
||||
const saving = ref(false)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const createModalOpen = ref(false)
|
||||
const creating = ref(false)
|
||||
const seasonToDelete = ref<{ id: number; year: number; name: string; isCurrent: boolean } | null>(null)
|
||||
const deleting = ref(false)
|
||||
const completing = ref(false)
|
||||
const seasonAuditEntries = ref<AdminAuditEntry[]>([])
|
||||
const loadingSeasonAudit = ref(false)
|
||||
let seasonAuditRequestId = 0
|
||||
|
||||
const form = reactive(createEmptySeasonForm())
|
||||
const createForm = reactive(createEmptyCreateForm())
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const selectedSeason = computed(() =>
|
||||
store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
|
||||
)
|
||||
const phasePresets = ['Nominierung', 'Community Voting', 'Review & Auswertung', 'Award Show', 'Abgeschlossen']
|
||||
const readinessItems = computed(() => buildReadinessItems(seasonDetail.value, form.currentPhase))
|
||||
const publicReadinessIssues = computed(() =>
|
||||
readinessItems.value
|
||||
.filter((item) => item.blocking && !item.complete)
|
||||
.map((item) => item.note),
|
||||
)
|
||||
const archiveReadinessIssues = computed(() => buildArchiveReadinessIssues(seasonDetail.value))
|
||||
const createPublicReadinessIssues = computed(() => buildCreatePublicReadinessIssues(createForm))
|
||||
const canActivatePublic = computed(() => publicReadinessIssues.value.length === 0)
|
||||
const canCompleteSelectedSeason = computed(() =>
|
||||
Boolean(
|
||||
selectedSeason.value &&
|
||||
normalizePhaseKey(form.currentPhase) !== 'completed' &&
|
||||
archiveReadinessIssues.value.length === 0,
|
||||
),
|
||||
)
|
||||
const canDeleteSelectedSeason = computed(() => Boolean(selectedSeason.value && !selectedSeason.value.isCurrent))
|
||||
const copySourceOptions = computed<AdminSeasonCopyOption[]>(() => [
|
||||
{ label: 'Keine Struktur kopieren', value: null },
|
||||
...store.adminSeasons.map((season) => ({
|
||||
label: `${season.year} · ${season.name} (${season.categoryCount} Kategorien)`,
|
||||
value: season.id,
|
||||
})),
|
||||
])
|
||||
const canCreate = computed(() =>
|
||||
Boolean(
|
||||
createForm.year &&
|
||||
createForm.name.trim() &&
|
||||
createForm.showStreamUrl.trim() &&
|
||||
createForm.currentPhase.trim() &&
|
||||
createForm.nominationStartsAt &&
|
||||
createForm.nominationEndsAt &&
|
||||
createForm.votingStartsAt &&
|
||||
createForm.votingEndsAt &&
|
||||
createForm.reviewStartsAt &&
|
||||
createForm.reviewEndsAt &&
|
||||
createForm.showDate &&
|
||||
createForm.showStartsAt &&
|
||||
createPublicReadinessIssues.value.length === 0,
|
||||
),
|
||||
)
|
||||
const latestSeasonAuditEntry = computed(() => seasonAuditEntries.value[0] ?? null)
|
||||
const latestSeasonAuditSummary = computed(() =>
|
||||
latestSeasonAuditEntry.value?.summary ?? 'Noch keine Aktion fuer dieses Jahr gefunden.',
|
||||
)
|
||||
const latestSeasonAuditMeta = computed(() => {
|
||||
const entry = latestSeasonAuditEntry.value
|
||||
return entry ? `${formatAuditDate(entry.createdAt)} · ${entry.adminTwitchUserId}` : 'Audit wird nach der ersten Aenderung angezeigt.'
|
||||
})
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
form.year = detail.year
|
||||
form.name = detail.name
|
||||
form.showStreamUrl = detail.showStreamUrl
|
||||
form.currentPhase = detail.currentPhase
|
||||
form.isCurrent = detail.isCurrent
|
||||
form.isCommunityOnly = detail.isCommunityOnly
|
||||
form.nominationStartsAt = detail.nominationStartsAt
|
||||
form.nominationEndsAt = detail.nominationEndsAt
|
||||
form.votingStartsAt = detail.votingStartsAt
|
||||
form.votingEndsAt = detail.votingEndsAt
|
||||
form.reviewStartsAt = detail.reviewStartsAt
|
||||
form.reviewEndsAt = detail.reviewEndsAt
|
||||
form.showDate = detail.showDate
|
||||
form.showStartsAt = normalizeTimeInput(detail.showStartsAt)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => createForm.year,
|
||||
(year) => {
|
||||
if (year >= 2020 && (!createForm.name || createForm.name.startsWith('VTuber Star Awards '))) {
|
||||
createForm.name = `VTuber Star Awards ${year}`
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [selectedSeasonId.value, seasonDetail.value.year, seasonDetail.value.name] as const,
|
||||
() => {
|
||||
void loadSeasonAuditEntries()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function fillCreateDefaults(year = new Date().getFullYear() + 1) {
|
||||
createForm.year = year
|
||||
createForm.name = `VTuber Star Awards ${year}`
|
||||
createForm.showStreamUrl = 'https://twitch.tv/jayuhime'
|
||||
createForm.currentPhase = 'Nominierung'
|
||||
createForm.isCurrent = false
|
||||
createForm.isCommunityOnly = true
|
||||
createForm.nominationStartsAt = `${year}-08-01`
|
||||
createForm.nominationEndsAt = `${year}-08-24`
|
||||
createForm.votingStartsAt = `${year}-08-25`
|
||||
createForm.votingEndsAt = `${year}-09-11`
|
||||
createForm.reviewStartsAt = `${year}-09-12`
|
||||
createForm.reviewEndsAt = `${year}-09-15`
|
||||
createForm.showDate = `${year}-09-20`
|
||||
createForm.showStartsAt = '20:00'
|
||||
createForm.copyStructureFromSeasonId = findCopySourceForYear(year)
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
fillCreateDefaults(
|
||||
Math.max(new Date().getFullYear(), (store.adminSeasons[0]?.year ?? new Date().getFullYear()) + 1),
|
||||
)
|
||||
createModalOpen.value = true
|
||||
}
|
||||
|
||||
async function persistSeason(successMessage: string) {
|
||||
if (!selectedSeasonId.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const completesSeasonNow =
|
||||
normalizePhaseKey(form.currentPhase) === 'completed' &&
|
||||
normalizePhaseKey(seasonDetail.value.currentPhase) !== 'completed'
|
||||
if (completesSeasonNow && archiveReadinessIssues.value.length > 0) {
|
||||
adminMessage.value = ''
|
||||
adminError.value = `Abschluss blockiert: ${archiveReadinessIssues.value.join(' ')}`
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.isCurrent && publicReadinessIssues.value.length > 0) {
|
||||
adminMessage.value = ''
|
||||
adminError.value = `Public-Aktivierung blockiert: ${publicReadinessIssues.value.join(' ')}`
|
||||
return false
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminSeason(selectedSeasonId.value, normalizeSeasonPayload(form))
|
||||
await loadSeasonAuditEntries()
|
||||
adminMessage.value = successMessage
|
||||
return true
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Jahr konnte nicht gespeichert werden.'
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSeason() {
|
||||
return persistSeason('Jahresstatus gespeichert.')
|
||||
}
|
||||
|
||||
async function activatePhase(phase: string) {
|
||||
if (!selectedSeasonId.value || saving.value || form.currentPhase === phase) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousPhase = form.currentPhase
|
||||
form.currentPhase = phase
|
||||
const saved = await persistSeason(`Phase „${phase}“ wurde aktiviert.`)
|
||||
if (!saved) {
|
||||
form.currentPhase = previousPhase
|
||||
}
|
||||
}
|
||||
|
||||
async function completeSeason() {
|
||||
if (!selectedSeasonId.value || completing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
completing.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
if (archiveReadinessIssues.value.length > 0) {
|
||||
adminError.value = `Abschluss blockiert: ${archiveReadinessIssues.value.join(' ')}`
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...form,
|
||||
currentPhase: 'Abgeschlossen',
|
||||
}
|
||||
await store.updateAdminSeason(selectedSeasonId.value, normalizeSeasonPayload(payload))
|
||||
await loadSeasonAuditEntries()
|
||||
form.currentPhase = 'Abgeschlossen'
|
||||
adminMessage.value = `Award-Jahr ${form.year} wurde abgeschlossen.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Award-Jahr konnte nicht abgeschlossen werden.'
|
||||
} finally {
|
||||
completing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSeason() {
|
||||
if (!canCreate.value) {
|
||||
if (createPublicReadinessIssues.value.length > 0) {
|
||||
adminError.value = `Direkte Public-Aktivierung blockiert: ${createPublicReadinessIssues.value.join(' ')}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.createAdminSeason(normalizeSeasonPayload(createForm))
|
||||
createModalOpen.value = false
|
||||
adminMessage.value = createForm.copyStructureFromSeasonId
|
||||
? `Award-Jahr ${createForm.year} wurde mit kopierter Struktur angelegt.`
|
||||
: `Award-Jahr ${createForm.year} wurde angelegt.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Award-Jahr konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteSeasonModal() {
|
||||
if (!selectedSeason.value) {
|
||||
return
|
||||
}
|
||||
|
||||
seasonToDelete.value = {
|
||||
id: selectedSeason.value.id,
|
||||
year: selectedSeason.value.year,
|
||||
name: selectedSeason.value.name,
|
||||
isCurrent: selectedSeason.value.isCurrent,
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSeason() {
|
||||
if (!seasonToDelete.value || seasonToDelete.value.isCurrent) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
const deletedYear = seasonToDelete.value.year
|
||||
await store.deleteAdminSeason(seasonToDelete.value.id)
|
||||
await loadSeasonAuditEntries()
|
||||
seasonToDelete.value = null
|
||||
adminMessage.value = `Award-Jahr ${deletedYear} wurde gelöscht.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Award-Jahr konnte nicht gelöscht werden.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function findCopySourceForYear(year: number) {
|
||||
const previousSeason = [...store.adminSeasons]
|
||||
.filter((season) => season.year < year)
|
||||
.sort((left, right) => right.year - left.year)[0]
|
||||
|
||||
return previousSeason?.id ?? store.adminSeasons[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function loadSeasonAuditEntries() {
|
||||
const detail = seasonDetail.value
|
||||
if (!detail.id) {
|
||||
seasonAuditEntries.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++seasonAuditRequestId
|
||||
loadingSeasonAudit.value = true
|
||||
|
||||
try {
|
||||
const entries = await api.getAdminAuditEntries(100, 'season')
|
||||
if (requestId !== seasonAuditRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
const knownEntityIds = new Set([String(detail.id), String(detail.year)])
|
||||
seasonAuditEntries.value = entries
|
||||
.filter((entry) =>
|
||||
entry.entityType === 'season' &&
|
||||
knownEntityIds.has(entry.entityId) &&
|
||||
entry.actionType.startsWith('season.'),
|
||||
)
|
||||
.slice(0, 5)
|
||||
} catch {
|
||||
if (requestId === seasonAuditRequestId) {
|
||||
seasonAuditEntries.value = []
|
||||
}
|
||||
} finally {
|
||||
if (requestId === seasonAuditRequestId) {
|
||||
loadingSeasonAudit.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
form,
|
||||
createForm,
|
||||
saving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
createModalOpen,
|
||||
creating,
|
||||
completing,
|
||||
seasonToDelete,
|
||||
deleting,
|
||||
seasonDetail,
|
||||
selectedSeasonId,
|
||||
selectedSeason,
|
||||
readinessItems,
|
||||
publicReadinessIssues,
|
||||
archiveReadinessIssues,
|
||||
createPublicReadinessIssues,
|
||||
canActivatePublic,
|
||||
phasePresets,
|
||||
canDeleteSelectedSeason,
|
||||
canCompleteSelectedSeason,
|
||||
copySourceOptions,
|
||||
loadingSeasonAudit,
|
||||
latestSeasonAuditSummary,
|
||||
latestSeasonAuditMeta,
|
||||
canCreate,
|
||||
activatePhase,
|
||||
openCreateModal,
|
||||
saveSeason,
|
||||
completeSeason,
|
||||
createSeason,
|
||||
openDeleteSeasonModal,
|
||||
confirmDeleteSeason,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTimeInput(value: string) {
|
||||
return value ? value.slice(0, 5) : '20:00'
|
||||
}
|
||||
|
||||
function normalizeTimeForApi(value: string) {
|
||||
const normalized = normalizeTimeInput(value)
|
||||
return normalized.length === 5 ? `${normalized}:00` : normalized
|
||||
}
|
||||
|
||||
function normalizeSeasonPayload<T extends { showStartsAt: string }>(payload: T): T {
|
||||
return {
|
||||
...payload,
|
||||
showStartsAt: normalizeTimeForApi(payload.showStartsAt),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSeasonFacts(detail: AdminSeasonDetailResponse) {
|
||||
const categoryIdsWithCandidates = new Set(detail.candidates.map((candidate) => candidate.categoryId))
|
||||
const categoryIdsWithResults = new Set(detail.results.map((result) => result.categoryId))
|
||||
const emptyCategories = detail.categories.filter((category) => !categoryIdsWithCandidates.has(category.id)).length
|
||||
const missingResults = detail.categories.filter((category) => !categoryIdsWithResults.has(category.id)).length
|
||||
const pendingClips = detail.clipSubmissions.filter((clip) => clip.status === 'pending').length
|
||||
|
||||
return {
|
||||
emptyCategories,
|
||||
missingResults,
|
||||
pendingClips,
|
||||
}
|
||||
}
|
||||
|
||||
function buildReadinessItems(
|
||||
detail: AdminSeasonDetailResponse,
|
||||
currentPhase: string,
|
||||
): AdminSeasonReadinessItem[] {
|
||||
const facts = resolveSeasonFacts(detail)
|
||||
const phaseKey = normalizePhaseKey(currentPhase)
|
||||
const candidateBlocking = phaseKey !== 'nomination'
|
||||
const winnerBlocking = phaseKey === 'completed'
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Kategorien',
|
||||
note: detail.categories.length > 0
|
||||
? `${detail.categories.length} Kategorien vorbereitet`
|
||||
: 'Mindestens eine Kategorie ist erforderlich.',
|
||||
complete: detail.categories.length > 0,
|
||||
blocking: true,
|
||||
to: '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Kandidatenbasis',
|
||||
note: facts.emptyCategories === 0 && detail.candidates.length > 0
|
||||
? 'Alle Kategorien haben Kandidaten.'
|
||||
: candidateBlocking
|
||||
? `${facts.emptyCategories} Kategorien brauchen noch Kandidaten.`
|
||||
: 'In der Nominierung duerfen Kategorien noch leer sein.',
|
||||
complete: detail.categories.length > 0 && facts.emptyCategories === 0 && detail.candidates.length > 0,
|
||||
blocking: candidateBlocking,
|
||||
to: '/admin/candidates',
|
||||
},
|
||||
{
|
||||
label: 'Reviews',
|
||||
note: detail.pendingNominations.length === 0
|
||||
? 'Keine offenen Nominierungsreviews.'
|
||||
: `${detail.pendingNominations.length} Reviews sollten vor Voting-Freeze entschieden werden.`,
|
||||
complete: detail.pendingNominations.length === 0,
|
||||
blocking: false,
|
||||
to: '/admin/nominations?review=1',
|
||||
},
|
||||
{
|
||||
label: 'Clips',
|
||||
note: facts.pendingClips === 0
|
||||
? 'Keine offenen Clip-Pruefungen.'
|
||||
: `${facts.pendingClips} Clips warten noch auf Pruefung.`,
|
||||
complete: facts.pendingClips === 0,
|
||||
blocking: false,
|
||||
to: '/admin/clips',
|
||||
},
|
||||
{
|
||||
label: 'Gewinner / Archiv',
|
||||
note: detail.categories.length > 0 && facts.missingResults === 0
|
||||
? 'Alle Gewinner sind vergeben.'
|
||||
: winnerBlocking
|
||||
? `${facts.missingResults} Kategorien brauchen vor Abschluss einen Gewinner.`
|
||||
: 'Vor Abschluss muessen alle Gewinner gesetzt sein.',
|
||||
complete: detail.categories.length > 0 && facts.missingResults === 0,
|
||||
blocking: winnerBlocking,
|
||||
to: '/admin/analytics',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildArchiveReadinessIssues(detail: AdminSeasonDetailResponse) {
|
||||
const facts = resolveSeasonFacts(detail)
|
||||
const issues: string[] = []
|
||||
|
||||
if (!hasShowDayPassed(detail)) {
|
||||
issues.push(
|
||||
detail.showDate
|
||||
? 'Die Award Show liegt noch nicht in der Vergangenheit.'
|
||||
: 'Lege zuerst ein Datum für die Award Show fest.',
|
||||
)
|
||||
}
|
||||
|
||||
if (detail.categories.length === 0) {
|
||||
issues.push('Mindestens eine Kategorie ist erforderlich.')
|
||||
}
|
||||
|
||||
if (facts.emptyCategories > 0) {
|
||||
issues.push(`${facts.emptyCategories} Kategorien haben noch keine Kandidaten.`)
|
||||
}
|
||||
|
||||
if (facts.missingResults > 0) {
|
||||
issues.push(`${facts.missingResults} Kategorien haben noch keinen Gewinner.`)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function buildCreatePublicReadinessIssues(createForm: AdminSeasonCreateForm) {
|
||||
if (!createForm.isCurrent) {
|
||||
return []
|
||||
}
|
||||
|
||||
const phaseKey = normalizePhaseKey(createForm.currentPhase)
|
||||
const issues: string[] = []
|
||||
if (!createForm.copyStructureFromSeasonId) {
|
||||
issues.push('Kopiere erst eine Vorjahres-Struktur oder lege Kategorien nach dem Erstellen manuell an.')
|
||||
}
|
||||
|
||||
if (phaseKey !== 'nomination') {
|
||||
issues.push('Direkt public ist fuer neue Jahre nur in der Nominierungsphase sinnvoll, weil Kandidaten noch fehlen.')
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function formatAuditDate(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { CheckCircle2, Database, FileText, Link2, ShieldCheck, Tags, UserRound } from '@lucide/vue'
|
||||
|
||||
import { getRiskMetricValue } from '../../lib/adminMetrics'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminSettingsCheckItem, AdminSettingsGateItem } from './adminSettingsTypes'
|
||||
|
||||
export function useAdminSettingsOverview() {
|
||||
const store = useAwardsStore()
|
||||
const healthLoading = ref(false)
|
||||
const healthError = ref('')
|
||||
const healthLoadedAt = ref<Date | null>(null)
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const databaseHealth = computed(() => store.databaseHealth)
|
||||
const hasVotingPhase = computed(() => seasonDetail.value.currentPhase.toLowerCase().includes('voting'))
|
||||
const categoriesWithoutCandidates = computed(() =>
|
||||
seasonDetail.value.categories.filter((category) =>
|
||||
!seasonDetail.value.candidates.some((candidate) => candidate.categoryId === category.id),
|
||||
),
|
||||
)
|
||||
const pendingClips = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
|
||||
const pendingMigrationCount = computed(() => databaseHealth.value.pendingMigrations.length)
|
||||
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
|
||||
const healthLoadedLabel = computed(() =>
|
||||
healthLoadedAt.value ? healthLoadedAt.value.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : 'Noch nicht aktualisiert',
|
||||
)
|
||||
const siteSettings = computed(() => store.adminSiteSettings)
|
||||
const configuredFooterLinks = computed(() => [
|
||||
siteSettings.value.imprintUrl,
|
||||
siteSettings.value.contactUrl,
|
||||
siteSettings.value.sponsorsUrl,
|
||||
siteSettings.value.newsletterUrl,
|
||||
].filter((url) => url.trim()).length)
|
||||
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
|
||||
{
|
||||
label: 'Host',
|
||||
value: Boolean(siteSettings.value.hostDisplayName.trim() && siteSettings.value.hostTagline.trim()),
|
||||
note: siteSettings.value.hostDisplayName.trim()
|
||||
? siteSettings.value.hostTagline.trim() ? siteSettings.value.hostDisplayName.trim() : 'Host-Tagline fehlt'
|
||||
: 'Kein Host-Name hinterlegt',
|
||||
icon: UserRound,
|
||||
to: '/admin/content',
|
||||
},
|
||||
{
|
||||
label: 'Social Links',
|
||||
value: siteSettings.value.socialLinks.length > 0,
|
||||
note: `${siteSettings.value.socialLinks.length} Links gespeichert`,
|
||||
icon: Link2,
|
||||
to: '/admin/content',
|
||||
},
|
||||
{
|
||||
label: 'FAQ',
|
||||
value: siteSettings.value.faq.length > 0,
|
||||
note: `${siteSettings.value.faq.length} Fragen gespeichert`,
|
||||
icon: FileText,
|
||||
to: '/admin/content',
|
||||
},
|
||||
{
|
||||
label: 'Footer Links',
|
||||
value: configuredFooterLinks.value === 4,
|
||||
note: `${configuredFooterLinks.value} von 4 Link-Zielen gepflegt`,
|
||||
icon: Link2,
|
||||
to: '/admin/content',
|
||||
},
|
||||
{
|
||||
label: 'Datenschutz',
|
||||
value: Boolean(siteSettings.value.privacyPolicyContent.trim() && siteSettings.value.privacyEmail.trim()),
|
||||
note: siteSettings.value.privacyPolicyUpdatedAt
|
||||
? `Zuletzt aktualisiert: ${new Date(siteSettings.value.privacyPolicyUpdatedAt).toLocaleString('de-DE')}`
|
||||
: 'Datenschutztext oder Datenschutz-Mail fehlt',
|
||||
icon: ShieldCheck,
|
||||
to: '/admin/content',
|
||||
},
|
||||
])
|
||||
const contentCompletion = computed(() => contentChecks.value.filter((check) => check.value).length)
|
||||
|
||||
const checks = computed<AdminSettingsCheckItem[]>(() => [
|
||||
{
|
||||
label: 'Backend verbunden',
|
||||
value: store.apiMode === 'api',
|
||||
note: store.apiMode === 'api' ? 'Admin-Daten kommen aus der API.' : 'Fallback-Daten aktiv oder API nicht erreichbar.',
|
||||
icon: Database,
|
||||
to: null,
|
||||
},
|
||||
{
|
||||
label: 'Postgres verbunden',
|
||||
value: databaseHealth.value.canConnect,
|
||||
note: databaseHealth.value.canConnect
|
||||
? `${databaseHealth.value.provider} erreichbar · Quelle: ${databaseHealth.value.configuredConnection.source}.`
|
||||
: databaseHealth.value.error || 'Datenbank ist nicht erreichbar.',
|
||||
icon: Database,
|
||||
to: null,
|
||||
},
|
||||
{
|
||||
label: 'Migrationen aktuell',
|
||||
value: databaseHealth.value.canConnect && pendingMigrationCount.value === 0,
|
||||
note: pendingMigrationCount.value === 0
|
||||
? 'Keine ausstehenden Migrationen.'
|
||||
: `${pendingMigrationCount.value} Migrationen warten auf Anwendung.`,
|
||||
icon: CheckCircle2,
|
||||
to: null,
|
||||
},
|
||||
{
|
||||
label: 'Public-Jahr gesetzt',
|
||||
value: seasonDetail.value.isCurrent,
|
||||
note: seasonDetail.value.isCurrent ? `${seasonDetail.value.year} ist öffentlich markiert.` : 'Das gewählte Jahr ist aktuell intern.',
|
||||
icon: CheckCircle2,
|
||||
to: '/admin/years',
|
||||
},
|
||||
{
|
||||
label: 'Voting-Basis vollständig',
|
||||
value: categoriesWithoutCandidates.value.length === 0 && seasonDetail.value.categories.length > 0,
|
||||
note: categoriesWithoutCandidates.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${categoriesWithoutCandidates.value.length} Kategorien brauchen Kandidaten.`,
|
||||
icon: Tags,
|
||||
to: '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Risiko-Queue leer',
|
||||
value: openRiskCount.value === 0,
|
||||
note: `${openRiskCount.value} offene Risikohinweise im Admin-Kontext.`,
|
||||
icon: ShieldCheck,
|
||||
to: '/admin/risk',
|
||||
},
|
||||
])
|
||||
const gates = computed<AdminSettingsGateItem[]>(() => [
|
||||
{
|
||||
label: 'Nominierungsfenster',
|
||||
state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'),
|
||||
note: seasonDetail.value.currentPhase.toLowerCase().includes('nomin')
|
||||
? 'Öffentliche Nominierung ist aktiv.'
|
||||
: 'Die aktuelle Phase erlaubt keine neuen Nominierungen.',
|
||||
to: '/admin/years',
|
||||
},
|
||||
{
|
||||
label: 'Voting freigeschaltet',
|
||||
state: hasVotingPhase.value && categoriesWithoutCandidates.value.length === 0,
|
||||
note: hasVotingPhase.value ? 'Phase ist Voting; Kategorie-Readiness entscheidet.' : 'Phase ist nicht Voting.',
|
||||
to: '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Clip-Reviews offen',
|
||||
state: pendingClips.value > 0,
|
||||
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
|
||||
to: '/admin/clips',
|
||||
},
|
||||
{
|
||||
label: 'Freitext-Reviews geklärt',
|
||||
state: seasonDetail.value.pendingNominations.length === 0,
|
||||
note: `${seasonDetail.value.pendingNominations.length} offene Freitext-Reviews.`,
|
||||
to: '/admin/nominations?review=1',
|
||||
},
|
||||
])
|
||||
|
||||
async function refreshDatabaseHealth() {
|
||||
healthLoading.value = true
|
||||
healthError.value = ''
|
||||
try {
|
||||
await store.loadDatabaseHealth()
|
||||
healthLoadedAt.value = new Date()
|
||||
} catch (error) {
|
||||
healthError.value = error instanceof Error ? error.message : 'Datenbank-Healthcheck konnte nicht geladen werden.'
|
||||
} finally {
|
||||
healthLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshDatabaseHealth)
|
||||
|
||||
return {
|
||||
healthLoading,
|
||||
healthError,
|
||||
databaseHealth,
|
||||
pendingMigrationCount,
|
||||
healthLoadedLabel,
|
||||
contentChecks,
|
||||
contentCompletion,
|
||||
checks,
|
||||
gates,
|
||||
refreshDatabaseHealth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const allFilter = 'all'
|
||||
|
||||
export function useAdminWinnersManager() {
|
||||
const store = useAwardsStore()
|
||||
const savingResultForCategory = ref<number | null>(null)
|
||||
const deletingResultId = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const query = ref('')
|
||||
const statusFilter = ref(allFilter)
|
||||
const winnerSelections = reactive<Record<number, string>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
|
||||
|
||||
const resultRows = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => {
|
||||
const candidates = seasonDetail.value.candidates
|
||||
.filter((candidate) => candidate.categoryId === category.id)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
const existing = resultMap.value.get(category.id) ?? null
|
||||
const openReviews = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
|
||||
const approvedClips = seasonDetail.value.clipSubmissions.filter((clip) => clip.categoryId === category.id && clip.status === 'approved').length
|
||||
|
||||
return {
|
||||
category,
|
||||
candidates,
|
||||
existing,
|
||||
openReviews,
|
||||
approvedClips,
|
||||
hasPendingReviews: openReviews > 0,
|
||||
isEmpty: candidates.length === 0,
|
||||
isComplete: Boolean(existing),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.category.sortOrder - b.category.sortOrder),
|
||||
)
|
||||
|
||||
const completedCount = computed(() => resultRows.value.filter((row) => row.isComplete).length)
|
||||
const missingCount = computed(() => Math.max(0, resultRows.value.length - completedCount.value))
|
||||
const reviewWarningCount = computed(() => resultRows.value.filter((row) => row.hasPendingReviews).length)
|
||||
const emptyCount = computed(() => resultRows.value.filter((row) => row.isEmpty).length)
|
||||
const completionPct = computed(() => resultRows.value.length === 0 ? 0 : Math.round((completedCount.value / resultRows.value.length) * 100))
|
||||
|
||||
const statusFilters = computed(() => [
|
||||
{ value: allFilter, label: 'Alle', count: resultRows.value.length },
|
||||
{ value: 'open', label: 'Offen', count: missingCount.value },
|
||||
{ value: 'set', label: 'Gesetzt', count: completedCount.value },
|
||||
{ value: 'review', label: 'Reviews offen', count: reviewWarningCount.value },
|
||||
{ value: 'empty', label: 'Ohne Kandidaten', count: emptyCount.value },
|
||||
])
|
||||
|
||||
const visibleResultRows = computed(() => {
|
||||
const normalizedQuery = query.value.trim().toLowerCase()
|
||||
|
||||
return resultRows.value.filter((row) => {
|
||||
const matchesStatus = statusFilter.value === allFilter
|
||||
|| (statusFilter.value === 'open' && !row.isComplete)
|
||||
|| (statusFilter.value === 'set' && row.isComplete)
|
||||
|| (statusFilter.value === 'review' && row.hasPendingReviews)
|
||||
|| (statusFilter.value === 'empty' && row.isEmpty)
|
||||
|
||||
if (!matchesStatus) return false
|
||||
if (!normalizedQuery) return true
|
||||
|
||||
return [
|
||||
row.category.name,
|
||||
row.category.groupName,
|
||||
row.existing?.candidateDisplayName ?? '',
|
||||
...row.candidates.map((candidate) => `${candidate.displayName} ${candidate.channelSlug} ${candidate.platform}`),
|
||||
].some((value) => value.toLowerCase().includes(normalizedQuery))
|
||||
})
|
||||
})
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{
|
||||
label: 'Fortschritt',
|
||||
value: `${completionPct.value}%`,
|
||||
note: `${completedCount.value} von ${resultRows.value.length} Kategorien final.`,
|
||||
icon: Trophy,
|
||||
tone: 'border-violet-100 bg-violet-50 text-violet-800',
|
||||
},
|
||||
{
|
||||
label: 'Offen',
|
||||
value: String(missingCount.value),
|
||||
note: missingCount.value === 0 ? 'Keine Gewinner fehlen.' : 'Diese Kategorien brauchen eine Auswahl.',
|
||||
icon: ListChecks,
|
||||
tone: missingCount.value > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
label: 'Reviews',
|
||||
value: String(reviewWarningCount.value),
|
||||
note: reviewWarningCount.value === 0 ? 'Keine Review-Warnungen.' : 'Vor finaler Freigabe pruefen.',
|
||||
icon: Clock3,
|
||||
tone: reviewWarningCount.value > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
label: 'Gesetzt',
|
||||
value: String(completedCount.value),
|
||||
note: 'Speist Archiv und Gewinneransicht.',
|
||||
icon: CheckCircle2,
|
||||
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
])
|
||||
|
||||
watch(resultRows, (rows) => {
|
||||
const knownCategoryIds = new Set(rows.map((row) => row.category.id))
|
||||
for (const key of Object.keys(winnerSelections)) {
|
||||
if (!knownCategoryIds.has(Number(key))) {
|
||||
delete winnerSelections[Number(key)]
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
winnerSelections[row.category.id] = row.existing?.candidateId ? String(row.existing.candidateId) : ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function saveWinner(categoryId: number) {
|
||||
const candidateId = Number(winnerSelections[categoryId])
|
||||
if (!candidateId || !seasonDetail.value.id) return
|
||||
savingResultForCategory.value = categoryId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.setAdminResult(seasonDetail.value.id, { categoryId, candidateId })
|
||||
adminMessage.value = 'Gewinner wurde gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Gewinner konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
savingResultForCategory.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function clearWinner(resultId: number) {
|
||||
if (!seasonDetail.value.id) return
|
||||
deletingResultId.value = resultId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.deleteAdminResult(resultId, seasonDetail.value.id)
|
||||
adminMessage.value = 'Gewinner wurde entfernt.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Gewinner konnte nicht entfernt werden.'
|
||||
} finally {
|
||||
deletingResultId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adminError,
|
||||
adminMessage,
|
||||
completionPct,
|
||||
deletingResultId,
|
||||
query,
|
||||
savingResultForCategory,
|
||||
statusFilter,
|
||||
statusFilters,
|
||||
summaryCards,
|
||||
visibleResultRows,
|
||||
winnerSelections,
|
||||
clearWinner,
|
||||
saveWinner,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user