Refine admin panel workflows

This commit is contained in:
AzuTear
2026-06-18 14:29:40 +02:00
parent f4512eba2e
commit d015fcbe6f
14 changed files with 481 additions and 598 deletions
+73 -104
View File
@@ -1,23 +1,22 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Database, Settings, ShieldCheck } from '@lucide/vue'
import { computed } from 'vue'
import { CheckCircle2, Database, Settings, ShieldCheck, Tags, Vote } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const saving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const seasonDetail = computed(() => store.adminSeasonDetail)
const form = reactive({
currentPhase: '',
isCurrent: false,
})
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 checks = computed(() => [
{
@@ -25,137 +24,107 @@ const checks = computed(() => [
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: 'Public-Jahr gesetzt',
value: seasonDetail.value.isCurrent,
note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist öffentlich markiert.' : 'Dieses Jahr ist aktuell intern.',
note: seasonDetail.value.isCurrent ? `${seasonDetail.value.year} ist öffentlich markiert.` : 'Das gewählte Jahr ist aktuell intern.',
icon: CheckCircle2,
to: '/admin/years',
},
{
label: 'Review-Schutz aktiv',
value: store.admin.riskFlags.length >= 0,
note: `${store.admin.riskFlags.length} Risikohinweise im Admin-Kontext.`,
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: store.admin.riskFlags.length === 0,
note: `${store.admin.riskFlags.length} offene Risikohinweise im Admin-Kontext.`,
icon: ShieldCheck,
to: '/admin/risk',
},
])
const featureGates = computed(() => [
const gates = computed(() => [
{
label: 'Nominierungen',
state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'),
note: 'Public-Nominierungen sollten nur im passenden Zeitraum aktiv sein.',
note: 'Aktiv, wenn die Season-Phase auf Nominierung steht.',
to: '/admin/years',
},
{
label: 'Voting',
state: seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
note: 'Voting sollte erst aktiv sein, wenn Kategorien und Kandidaten gepflegt sind.',
},
{
label: 'Community-only Ergebnis',
state: true,
note: 'Aktuell als Community-basierte Auswertung geplant.',
state: hasVotingPhase.value && categoriesWithoutCandidates.value.length === 0,
note: hasVotingPhase.value ? 'Phase ist Voting; Kategorie-Readiness entscheidet.' : 'Phase ist nicht Voting.',
to: '/admin/voting',
},
{
label: 'Clip-Moderation',
state: true,
note: 'Clip-Einreichungen laufen in den Clips-Bereich und können dort moderiert werden.',
state: pendingClips.value > 0,
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
to: '/admin/clips',
},
{
label: 'Review-Freeze',
state: seasonDetail.value.pendingNominations.length === 0,
note: `${seasonDetail.value.pendingNominations.length} offene Freitext-Reviews.`,
to: '/admin/reviews',
},
])
watch(
seasonDetail,
(detail) => {
form.currentPhase = detail.currentPhase
form.isCurrent = detail.isCurrent
},
{ immediate: true },
)
async function saveSettings() {
if (!selectedSeasonId.value) return
saving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminSeason(selectedSeasonId.value, {
currentPhase: form.currentPhase,
isCurrent: form.isCurrent,
})
adminMessage.value = 'Einstellungen gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Einstellungen konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Einstellungen"
title="Public-Status und Systemchecks"
description="Hier liegen bewusst nur Einstellungen, die das aktuelle Award-Jahr oder die Admin-Betriebsbereitschaft betreffen. Kategorie-Inhalte bleiben in Kategorien/Jahre."
title="Systemchecks ohne Doppelpflege"
description="Diese Seite speichert keine Season-Daten mehr. Sie zeigt, ob API, Public-Jahr, Voting-Basis, Reviews, Clips und Risiko-Queue für den Award-Betrieb gesund sind."
:icon="Settings"
/>
<AdminSeasonToolbar />
<section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Award-Jahr</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Sichtbarkeit steuern</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
Diese Einstellungen werden gespeichert und beeinflussen, welches Jahr als aktueller Public-Kontext gilt.
</p>
<div class="mt-6 space-y-5">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Phase</span>
<input v-model="form.currentPhase" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Community Voting" />
</label>
<label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span>
<span class="block font-semibold text-slate-900">Dieses Award-Jahr öffentlich markieren</span>
<span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext für Community, Voting und später Archiv.</span>
</span>
</label>
</div>
<p v-if="adminMessage" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="mt-6 flex justify-end">
<Button :disabled="saving || !selectedSeasonId" @click="saveSettings">{{ saving ? 'Speichert ...' : 'Einstellungen speichern' }}</Button>
</div>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Checks</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Betriebsstatus</h2>
<div class="mt-6 space-y-3">
<div v-for="check in checks" :key="check.label" class="flex gap-4 rounded-[22px] border border-violet-100 bg-white/90 p-4">
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl" :class="check.value ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
<component :is="check.icon" class="h-5 w-5" />
</div>
<div>
<p class="font-semibold text-slate-900">{{ check.label }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ check.note }}</p>
</div>
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<RouterLink
v-for="check in checks"
:key="check.label"
:to="check.to ?? '/admin/settings'"
class="rounded-[26px] border bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.09)] transition hover:bg-violet-50/50"
:class="check.value ? 'border-emerald-100' : 'border-amber-100'"
>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="check.value ? 'text-emerald-600' : 'text-amber-600'">{{ check.label }}</p>
<p class="mt-3 text-sm leading-6 text-slate-600">{{ check.note }}</p>
</div>
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl" :class="check.value ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
<component :is="check.icon" class="h-5 w-5" />
</div>
</div>
</Card>
</RouterLink>
</section>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Feature Gates</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was ist aktuell aktiv?</h2>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Feature Gates</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was ist wirklich aktiv?</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Die Gates sind aus aktuellen Daten abgeleitet und verlinken zum Ort, an dem der Zustand behoben wird.
</p>
</div>
<Vote class="h-6 w-6 text-violet-500" />
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<div
v-for="gate in featureGates"
<RouterLink
v-for="gate in gates"
:key="gate.label"
class="rounded-[22px] border p-4"
:to="gate.to"
class="rounded-[22px] border p-4 transition hover:bg-violet-50/50"
:class="gate.state ? 'border-emerald-100 bg-emerald-50/40' : 'border-slate-100 bg-slate-50/70'"
>
<div class="flex items-start justify-between gap-3">
@@ -167,7 +136,7 @@ async function saveSettings() {
{{ gate.state ? 'aktiv' : 'inaktiv' }}
</span>
</div>
</div>
</RouterLink>
</div>
</Card>
</div>