Improve admin navigation and local API reachability
This commit is contained in:
@@ -1 +1 @@
|
|||||||
VITE_API_URL=http://localhost:5084
|
VITE_API_URL=http://127.0.0.1:5084
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
eyebrow?: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p v-if="eyebrow" class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">{{ eyebrow }}</p>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-5xl leading-[0.95] text-violet-800">{{ title }}</h2>
|
||||||
|
<p class="max-w-3xl text-base leading-7 text-slate-600">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -21,16 +21,26 @@ const seasonOptions = computed(() =>
|
|||||||
value: season.id,
|
value: season.id,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentSeason = computed(() => store.adminSeasonDetail)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 rounded-[26px] border border-violet-100 bg-white/80 px-5 py-5 md:flex-row md:items-center md:justify-between">
|
<div class="flex flex-col gap-4 rounded-[26px] border border-violet-100 bg-white/80 px-5 py-5 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div class="space-y-3">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Arbeitskontext</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Arbeitskontext</p>
|
||||||
<p class="mt-2 text-sm text-slate-500">Die gewaehlte Season steuert Kategorien, Kandidaten und Review-Queues im gesamten Admin-Bereich.</p>
|
<p class="mt-2 text-sm text-slate-500">Die gewaehlte Season steuert Kategorien, Kandidaten und Review-Queues im gesamten Admin-Bereich.</p>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<span class="rounded-full border border-violet-100 bg-violet-50/70 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-600">
|
||||||
|
{{ currentSeason.currentPhase || 'Kein Status' }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-violet-100 bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-600">
|
||||||
|
{{ currentSeason.isCurrent ? 'Public Season' : 'Nicht aktiv' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-3 md:min-w-[330px]">
|
<div class="grid gap-3 lg:min-w-[330px]">
|
||||||
<label class="text-sm font-semibold text-slate-600">Season</label>
|
<label class="text-sm font-semibold text-slate-600">Season</label>
|
||||||
<Select
|
<Select
|
||||||
v-model="selectedSeasonId"
|
v-model="selectedSeasonId"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
WinnerArchiveResponse,
|
WinnerArchiveResponse,
|
||||||
} from '../types/awards'
|
} from '../types/awards'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:5084'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:5084'
|
||||||
const AUTH_TOKEN_KEY = 'vtsa-session-token'
|
const AUTH_TOKEN_KEY = 'vtsa-session-token'
|
||||||
|
|
||||||
function getAuthToken() {
|
function getAuthToken() {
|
||||||
@@ -31,6 +31,8 @@ async function getJson<T>(path: string): Promise<T> {
|
|||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
}).catch(() => {
|
||||||
|
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed for ${path}`)
|
throw new Error(`API request failed for ${path}`)
|
||||||
@@ -47,6 +49,8 @@ async function sendJson<TResponse>(path: string, method: 'POST' | 'PUT', body: u
|
|||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
}).catch(() => {
|
||||||
|
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const router = createRouter({
|
|||||||
component: NominationsView,
|
component: NominationsView,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
keepAlive: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -37,6 +38,7 @@ const router = createRouter({
|
|||||||
component: VotingView,
|
component: VotingView,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
keepAlive: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,26 +61,41 @@ const router = createRouter({
|
|||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
name: 'admin-dashboard',
|
name: 'admin-dashboard',
|
||||||
component: AdminDashboardView,
|
component: AdminDashboardView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'seasons',
|
path: 'seasons',
|
||||||
name: 'admin-seasons',
|
name: 'admin-seasons',
|
||||||
component: AdminSeasonsView,
|
component: AdminSeasonsView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'candidates',
|
path: 'candidates',
|
||||||
name: 'admin-candidates',
|
name: 'admin-candidates',
|
||||||
component: AdminCandidatesView,
|
component: AdminCandidatesView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'reviews',
|
path: 'reviews',
|
||||||
name: 'admin-reviews',
|
name: 'admin-reviews',
|
||||||
component: AdminReviewsView,
|
component: AdminReviewsView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'risk',
|
path: 'risk',
|
||||||
name: 'admin-risk',
|
name: 'admin-risk',
|
||||||
component: AdminRiskView,
|
component: AdminRiskView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import Select from 'primevue/select'
|
import Select from 'primevue/select'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
import Button from '../../components/ui/Button.vue'
|
import Button from '../../components/ui/Button.vue'
|
||||||
import Card from '../../components/ui/Card.vue'
|
import Card from '../../components/ui/Card.vue'
|
||||||
@@ -28,12 +29,26 @@ const candidateForms = reactive<Record<number, {
|
|||||||
|
|
||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
const candidateFilter = ref('')
|
||||||
const categoryOptions = computed(() =>
|
const categoryOptions = computed(() =>
|
||||||
seasonDetail.value.categories.map((category) => ({
|
seasonDetail.value.categories.map((category) => ({
|
||||||
label: `${category.groupName} · ${category.name}`,
|
label: `${category.groupName} · ${category.name}`,
|
||||||
value: category.id,
|
value: category.id,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
const categoryLabelMap = computed(() =>
|
||||||
|
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])),
|
||||||
|
)
|
||||||
|
const filteredCandidates = computed(() => {
|
||||||
|
const query = candidateFilter.value.trim().toLowerCase()
|
||||||
|
if (!query) return seasonDetail.value.candidates
|
||||||
|
return seasonDetail.value.candidates.filter((candidate) =>
|
||||||
|
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
seasonDetail,
|
seasonDetail,
|
||||||
@@ -92,6 +107,12 @@ async function createCandidate() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Candidates"
|
||||||
|
title="Kandidatenbasis pflegen"
|
||||||
|
description="Hier findest du alle Kandidaten der gewaehlten Season an einem Ort. Filtere zuerst und bearbeite dann nur die relevanten Eintraege."
|
||||||
|
/>
|
||||||
|
|
||||||
<AdminSeasonToolbar />
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
@@ -102,13 +123,25 @@ async function createCandidate() {
|
|||||||
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting und Archiv genutzt werden.</p>
|
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting und Archiv genutzt werden.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
{{ seasonDetail.candidates.length }} Kandidaten
|
{{ filteredCandidates.length }} / {{ seasonDetail.candidates.length }} Kandidaten
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<input
|
||||||
|
v-model="candidateFilter"
|
||||||
|
type="text"
|
||||||
|
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="Nach Name, Handle, Plattform oder Kategorie filtern"
|
||||||
|
/>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
Tipp: Nutze erst den Filter und aendere danach nur den Zielkandidaten.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<div
|
<div
|
||||||
v-for="candidate in seasonDetail.candidates"
|
v-for="candidate in filteredCandidates"
|
||||||
:key="candidate.id"
|
:key="candidate.id"
|
||||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||||
>
|
>
|
||||||
@@ -131,6 +164,10 @@ async function createCandidate() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="filteredCandidates.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Kandidaten passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import Column from 'primevue/column'
|
import Column from 'primevue/column'
|
||||||
import DataTable from 'primevue/datatable'
|
import DataTable from 'primevue/datatable'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import Card from '../../components/ui/Card.vue'
|
import Card from '../../components/ui/Card.vue'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
@@ -11,10 +13,55 @@ const store = useAwardsStore()
|
|||||||
const metrics = computed(() => store.admin.metrics)
|
const metrics = computed(() => store.admin.metrics)
|
||||||
const activities = computed(() => store.admin.activities)
|
const activities = computed(() => store.admin.activities)
|
||||||
const topCategories = computed(() => store.admin.topCategories)
|
const topCategories = computed(() => store.admin.topCategories)
|
||||||
|
const quickActions = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Offene Reviews',
|
||||||
|
value: store.adminSeasonDetail.pendingNominations.length,
|
||||||
|
to: '/admin/reviews',
|
||||||
|
hint: 'Freitext-Nominierungen bearbeiten',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Offene Risk Flags',
|
||||||
|
value: store.admin.riskFlags.length,
|
||||||
|
to: '/admin/risk',
|
||||||
|
hint: 'auffaellige Muster pruefen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kategorien pflegen',
|
||||||
|
value: store.adminSeasonDetail.categories.length,
|
||||||
|
to: '/admin/seasons',
|
||||||
|
hint: 'Texte, Limits und Sortierung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kandidatenbasis',
|
||||||
|
value: store.adminSeasonDetail.candidates.length,
|
||||||
|
to: '/admin/candidates',
|
||||||
|
hint: 'Kandidaten schnell aktualisieren',
|
||||||
|
},
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Dashboard"
|
||||||
|
title="Was braucht gerade Aufmerksamkeit?"
|
||||||
|
description="Das Dashboard zeigt dir zuerst die offenen Arbeitsstapel und fuehrt dich mit Quick Actions direkt in den passenden Bereich."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in quickActions"
|
||||||
|
:key="item.label"
|
||||||
|
:to="item.to"
|
||||||
|
class="block rounded-[28px] border border-violet-100 bg-white/80 p-6 text-slate-800 shadow-[0_18px_50px_rgba(168,145,214,0.1)] transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/50"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ item.label }}</p>
|
||||||
|
<strong class="mt-4 block text-4xl text-violet-800">{{ item.value }}</strong>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">{{ item.hint }}</p>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-5 lg:grid-cols-4">
|
<div class="grid gap-5 lg:grid-cols-4">
|
||||||
<Card
|
<Card
|
||||||
v-for="metric in metrics"
|
v-for="metric in metrics"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||||
|
import { Activity, AlertTriangle, CalendarCog, LayoutDashboard, Sparkles, Users } from '@lucide/vue'
|
||||||
|
|
||||||
import Card from '../../components/ui/Card.vue'
|
import Card from '../../components/ui/Card.vue'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
@@ -11,14 +12,20 @@ const store = useAwardsStore()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'Dashboard', to: '/admin/dashboard', description: 'KPIs, Trends, letzte Aktivitaet' },
|
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Status, Trends, Quick Actions', icon: LayoutDashboard, badge: () => null },
|
||||||
{ label: 'Seasons', to: '/admin/seasons', description: 'Season-Status, Kategorien, Limits' },
|
{ label: 'Seasons', to: '/admin/seasons', description: 'Season-Status, Kategorien, Limits', icon: CalendarCog, badge: () => `${store.adminSeasonDetail.categories.length}` },
|
||||||
{ label: 'Candidates', to: '/admin/candidates', description: 'Kandidatenbasis pro Season pflegen' },
|
{ label: 'Candidates', to: '/admin/candidates', description: 'Kandidatenbasis pro Season pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` },
|
||||||
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Nominierungen bearbeiten' },
|
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Nominierungen bearbeiten', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
|
||||||
{ label: 'Risk & Audit', to: '/admin/risk', description: 'Flags pruefen, Aktionen nachvollziehen' },
|
{ label: 'Risk & Audit', to: '/admin/risk', description: 'Flags pruefen, Aktionen nachvollziehen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentSeason = computed(() => store.adminSeasonDetail)
|
const currentSeason = computed(() => store.adminSeasonDetail)
|
||||||
|
const currentNavItem = computed(() => navItems.find((item) => route.path === item.to) ?? navItems[0])
|
||||||
|
const seasonSummary = computed(() => [
|
||||||
|
{ label: 'Kategorien', value: currentSeason.value.categories.length },
|
||||||
|
{ label: 'Kandidaten', value: currentSeason.value.candidates.length },
|
||||||
|
{ label: 'Reviews', value: currentSeason.value.pendingNominations.length },
|
||||||
|
])
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!authStore.isAdmin) return
|
if (!authStore.isAdmin) return
|
||||||
@@ -36,10 +43,42 @@ onMounted(async () => {
|
|||||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||||
Der Admin-Bereich ist in klar getrennte Arbeitszonen aufgeteilt, damit Season-Pflege, Review und Monitoring nicht mehr auf einer einzigen Seite kollidieren.
|
Der Admin-Bereich ist in klar getrennte Arbeitszonen aufgeteilt, damit Season-Pflege, Review und Monitoring nicht mehr auf einer einzigen Seite kollidieren.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<Card class="p-5">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="grid h-12 w-12 place-items-center rounded-[1.2rem] bg-violet-100 text-violet-700">
|
||||||
|
<component :is="currentNavItem.icon" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktueller Bereich</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ currentNavItem.label }}</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">{{ currentNavItem.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Activity class="h-5 w-5 text-violet-600" />
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Season Snapshot</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="item in seasonSummary"
|
||||||
|
:key="item.label"
|
||||||
|
class="rounded-[20px] border border-violet-100 bg-violet-50/60 px-4 py-4"
|
||||||
|
>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</p>
|
||||||
|
<strong class="mt-2 block text-2xl text-violet-800">{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
|
<div class="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
<Card class="h-fit p-4">
|
<Card class="h-fit p-4 xl:sticky xl:top-6">
|
||||||
<nav class="space-y-2">
|
<nav class="space-y-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
@@ -48,8 +87,23 @@ onMounted(async () => {
|
|||||||
class="block rounded-[24px] border px-4 py-4 transition"
|
class="block rounded-[24px] border px-4 py-4 transition"
|
||||||
:class="route.path === item.to ? 'border-violet-200 bg-violet-100/80 text-violet-900' : 'border-transparent bg-white/70 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
|
:class="route.path === item.to ? 'border-violet-200 bg-violet-100/80 text-violet-900' : 'border-transparent bg-white/70 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
|
||||||
>
|
>
|
||||||
<p class="text-sm font-semibold uppercase tracking-[0.18em]">{{ item.label }}</p>
|
<div class="flex items-start justify-between gap-3">
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ item.description }}</p>
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="mt-0.5 grid h-10 w-10 place-items-center rounded-[1rem] bg-white/80 text-violet-700">
|
||||||
|
<component :is="item.icon" class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold uppercase tracking-[0.18em]">{{ item.label }}</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="item.badge()"
|
||||||
|
class="rounded-full border border-violet-200 bg-white/90 px-3 py-1 text-[11px] font-semibold text-violet-700"
|
||||||
|
>
|
||||||
|
{{ item.badge() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
import Button from '../../components/ui/Button.vue'
|
import Button from '../../components/ui/Button.vue'
|
||||||
import Card from '../../components/ui/Card.vue'
|
import Card from '../../components/ui/Card.vue'
|
||||||
@@ -19,6 +20,17 @@ const reviewForms = reactive<Record<number, {
|
|||||||
|
|
||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
const reviewFilter = ref('')
|
||||||
|
const filteredNominations = computed(() => {
|
||||||
|
const query = reviewFilter.value.trim().toLowerCase()
|
||||||
|
if (!query) return seasonDetail.value.pendingNominations
|
||||||
|
return seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||||
|
[nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
seasonDetail,
|
seasonDetail,
|
||||||
@@ -71,6 +83,12 @@ async function rejectNomination(nominationId: number) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Reviews"
|
||||||
|
title="Freitext-Nominierungen sichten"
|
||||||
|
description="Alle uneindeutigen oder noch nicht gemappten Nominierungen laufen hier zusammen. Suche nach User, Kategorie oder Kandidat und entscheide dann direkt im Kontext."
|
||||||
|
/>
|
||||||
|
|
||||||
<AdminSeasonToolbar />
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
<Card class="p-7">
|
<Card class="p-7">
|
||||||
@@ -80,7 +98,7 @@ async function rejectNomination(nominationId: number) {
|
|||||||
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
|
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
{{ seasonDetail.pendingNominations.length }} offen
|
{{ filteredNominations.length }} / {{ seasonDetail.pendingNominations.length }} offen
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,9 +109,21 @@ async function rejectNomination(nominationId: number) {
|
|||||||
{{ adminError }}
|
{{ adminError }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<input
|
||||||
|
v-model="reviewFilter"
|
||||||
|
type="text"
|
||||||
|
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="Nach Kategorie, Nominierung oder User filtern"
|
||||||
|
/>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
Tipp: Suche erst nach dem Problemfall, dann entscheide uebernehmen oder verwerfen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<div
|
<div
|
||||||
v-for="nomination in seasonDetail.pendingNominations"
|
v-for="nomination in filteredNominations"
|
||||||
:key="nomination.id"
|
:key="nomination.id"
|
||||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||||
>
|
>
|
||||||
@@ -144,6 +174,9 @@ async function rejectNomination(nominationId: number) {
|
|||||||
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
Keine offenen Review-Faelle in der aktuell gewaehlten Season.
|
Keine offenen Review-Faelle in der aktuell gewaehlten Season.
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else-if="filteredNominations.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Review-Faelle passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import Button from '../../components/ui/Button.vue'
|
import Button from '../../components/ui/Button.vue'
|
||||||
import Card from '../../components/ui/Card.vue'
|
import Card from '../../components/ui/Card.vue'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
@@ -9,9 +10,31 @@ const store = useAwardsStore()
|
|||||||
const riskSaving = ref<number | null>(null)
|
const riskSaving = ref<number | null>(null)
|
||||||
const adminMessage = ref('')
|
const adminMessage = ref('')
|
||||||
const adminError = ref('')
|
const adminError = ref('')
|
||||||
|
const riskFilter = ref('')
|
||||||
|
const auditFilter = ref('')
|
||||||
|
|
||||||
const riskFlags = computed(() => store.admin.riskFlags)
|
const riskFlags = computed(() => store.admin.riskFlags)
|
||||||
const auditEntries = computed(() => store.admin.auditEntries)
|
const auditEntries = computed(() => store.admin.auditEntries)
|
||||||
|
const filteredRiskFlags = computed(() => {
|
||||||
|
const query = riskFilter.value.trim().toLowerCase()
|
||||||
|
if (!query) return riskFlags.value
|
||||||
|
return riskFlags.value.filter((flag) =>
|
||||||
|
[flag.source, flag.type, flag.summary, flag.twitchUserId ?? '', flag.createdFromIp]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const filteredAuditEntries = computed(() => {
|
||||||
|
const query = auditFilter.value.trim().toLowerCase()
|
||||||
|
if (!query) return auditEntries.value
|
||||||
|
return auditEntries.value.filter((entry) =>
|
||||||
|
[entry.summary, entry.adminTwitchUserId, entry.actionType, entry.entityType, entry.entityId]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||||
riskSaving.value = riskFlagId
|
riskSaving.value = riskFlagId
|
||||||
@@ -31,6 +54,12 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Risk & Audit"
|
||||||
|
title="Auffaellige Muster und Admin-Aktionen verfolgen"
|
||||||
|
description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgefuehrte Eingriffe deutlich schneller."
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
<Card class="p-7">
|
<Card class="p-7">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
@@ -39,7 +68,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
|||||||
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nominierungs- und Voting-Muster fuer die manuelle Sichtung.</p>
|
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nominierungs- und Voting-Muster fuer die manuelle Sichtung.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
{{ riskFlags.length }} offen
|
{{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,9 +79,21 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
|||||||
{{ adminError }}
|
{{ adminError }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<input
|
||||||
|
v-model="riskFilter"
|
||||||
|
type="text"
|
||||||
|
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="Risk Flags nach Typ, User oder IP filtern"
|
||||||
|
/>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
Tipp: Filtere erst auf den Problemtyp und resolve dann nur den geprueften Fall.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<div
|
<div
|
||||||
v-for="flag in riskFlags"
|
v-for="flag in filteredRiskFlags"
|
||||||
:key="flag.id"
|
:key="flag.id"
|
||||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||||
>
|
>
|
||||||
@@ -84,6 +125,9 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
|||||||
<p v-if="riskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
<p v-if="riskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
Keine offenen Risk Flags vorhanden.
|
Keine offenen Risk Flags vorhanden.
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else-if="filteredRiskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Risk Flags passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -94,13 +138,22 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
|||||||
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p>
|
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
{{ auditEntries.length }} Eintraege
|
{{ filteredAuditEntries.length }} / {{ auditEntries.length }} Eintraege
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<input
|
||||||
|
v-model="auditFilter"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="Audit-Eintraege nach Aktion, Admin oder Entitaet filtern"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<div
|
<div
|
||||||
v-for="entry in auditEntries"
|
v-for="entry in filteredAuditEntries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
|
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
|
||||||
>
|
>
|
||||||
@@ -118,6 +171,9 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
|||||||
<p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
<p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
Noch keine Audit-Eintraege vorhanden.
|
Noch keine Audit-Eintraege vorhanden.
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else-if="filteredAuditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Audit-Eintraege passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
import Button from '../../components/ui/Button.vue'
|
import Button from '../../components/ui/Button.vue'
|
||||||
import Card from '../../components/ui/Card.vue'
|
import Card from '../../components/ui/Card.vue'
|
||||||
@@ -37,6 +38,17 @@ const editForms = reactive<Record<number, {
|
|||||||
|
|
||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
const categoryFilter = ref('')
|
||||||
|
const filteredCategories = computed(() => {
|
||||||
|
const query = categoryFilter.value.trim().toLowerCase()
|
||||||
|
if (!query) return seasonDetail.value.categories
|
||||||
|
return seasonDetail.value.categories.filter((category) =>
|
||||||
|
[category.groupName, category.name, category.slug, category.description]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
seasonDetail,
|
seasonDetail,
|
||||||
@@ -123,6 +135,12 @@ async function createCategory() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Seasons"
|
||||||
|
title="Season und Kategorien verwalten"
|
||||||
|
description="Hier steuerst du die aktive Phase, legst neue Kategorien an und pflegst bestehende Gruppen, Limits und Beschreibungen."
|
||||||
|
/>
|
||||||
|
|
||||||
<AdminSeasonToolbar />
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
@@ -190,13 +208,25 @@ async function createCategory() {
|
|||||||
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
|
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
{{ seasonDetail.categories.length }} Kategorien
|
{{ filteredCategories.length }} / {{ seasonDetail.categories.length }} Kategorien
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<input
|
||||||
|
v-model="categoryFilter"
|
||||||
|
type="text"
|
||||||
|
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="Kategorien nach Name, Gruppe oder Slug filtern"
|
||||||
|
/>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
Tipp: Erst filtern, dann die passende Kategorie direkt bearbeiten.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<div
|
<div
|
||||||
v-for="category in seasonDetail.categories"
|
v-for="category in filteredCategories"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||||
>
|
>
|
||||||
@@ -223,6 +253,10 @@ async function createCategory() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="filteredCategories.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Kategorien passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user