Improve admin candidates interface

This commit is contained in:
AzuTear
2026-06-17 13:57:33 +02:00
parent 567f0e2ebf
commit 12cf63ef49
+185 -71
View File
@@ -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 Select from 'primevue/select' import Select from 'primevue/select'
import { Search, Sparkles, Tags, UserPlus, Users } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
@@ -30,25 +31,47 @@ 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 candidateFilter = ref('')
const categoryFilter = ref<number | null>(null)
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 categoryFilterOptions = computed(() => [
{ label: 'Alle Kategorien', value: null },
...categoryOptions.value,
])
const categoryLabelMap = computed(() => const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])), Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])),
) )
const platformSummary = computed(() => {
const platforms = new Set(seasonDetail.value.candidates.map((candidate) => candidate.platform).filter(Boolean))
return platforms.size
})
const candidateStats = computed(() => [
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'im Jahr gepflegt' },
{ label: 'Kategorien', value: seasonDetail.value.categories.length, note: 'als Ziel verfuegbar' },
{ label: 'Plattformen', value: platformSummary.value, note: 'in der Kandidatenbasis' },
])
const filteredCandidates = computed(() => { const filteredCandidates = computed(() => {
const query = candidateFilter.value.trim().toLowerCase() const query = candidateFilter.value.trim().toLowerCase()
if (!query) return seasonDetail.value.candidates const candidates = categoryFilter.value
return seasonDetail.value.candidates.filter((candidate) => ? seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === categoryFilter.value)
: seasonDetail.value.candidates
if (!query) return candidates
return candidates.filter((candidate) =>
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? ''] [candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
.includes(query), .includes(query),
) )
}) })
const hasFilters = computed(() => candidateFilter.value.trim().length > 0 || categoryFilter.value !== null)
const canCreateCandidate = computed(() =>
Boolean(selectedSeasonId.value && newCandidateForm.categoryId && newCandidateForm.displayName.trim() && newCandidateForm.channelSlug.trim()),
)
watch( watch(
seasonDetail, seasonDetail,
@@ -85,7 +108,7 @@ async function saveCandidate(candidateId: number) {
} }
async function createCandidate() { async function createCandidate() {
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return if (!canCreateCandidate.value || !selectedSeasonId.value) return
candidateSaving.value = 'new' candidateSaving.value = 'new'
adminMessage.value = '' adminMessage.value = ''
@@ -103,6 +126,11 @@ async function createCandidate() {
candidateSaving.value = null candidateSaving.value = null
} }
} }
function clearFilters() {
candidateFilter.value = ''
categoryFilter.value = null
}
</script> </script>
<template> <template>
@@ -110,92 +138,178 @@ async function createCandidate() {
<AdminPageHeader <AdminPageHeader
eyebrow="Candidates" eyebrow="Candidates"
title="Kandidatenbasis pflegen" title="Kandidatenbasis pflegen"
description="Hier findest du alle Kandidaten des gewaehlten Award-Jahres an einem Ort. Filtere zuerst und bearbeite dann nur die relevanten Eintraege." description="Schneller finden, sauber pruefen, gezielt bearbeiten: die Kandidaten sind jetzt nach Jahr, Kategorie und Handle besser steuerbar."
/> />
<AdminSeasonToolbar /> <AdminSeasonToolbar />
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> <div class="grid gap-4 md:grid-cols-3">
<Card class="p-7"> <Card
<div class="flex items-center justify-between gap-4"> v-for="stat in candidateStats"
<div> :key="stat.label"
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2> class="p-5"
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting und Archiv genutzt werden.</p> >
</div> <p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-500">{{ stat.label }}</p>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500"> <strong class="mt-2 block text-3xl text-violet-800">{{ stat.value }}</strong>
{{ filteredCandidates.length }} / {{ seasonDetail.candidates.length }} Kandidaten <p class="mt-1 text-sm text-slate-500">{{ stat.note }}</p>
</span> </Card>
</div> </div>
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"> <div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<input <Card class="overflow-hidden">
v-model="candidateFilter" <div class="border-b border-violet-100 bg-white/70 p-6">
type="text" <div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
class="rounded-2xl border border-violet-200 px-4 py-3" <div>
placeholder="Nach Name, Handle, Plattform oder Kategorie filtern" <p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kandidaten Center</p>
/> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Suchen, pruefen, aktualisieren</h2>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600"> <p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Tipp: Nutze erst den Filter und aendere danach nur den Zielkandidaten. Filtere nach Kategorie oder Handle und bearbeite nur den Kandidaten, der wirklich geaendert werden muss.
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
<strong class="text-violet-800">{{ filteredCandidates.length }}</strong> von {{ seasonDetail.candidates.length }} sichtbar
</div>
</div>
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_280px_auto]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="candidateFilter"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Name, Handle, Plattform oder Kategorie suchen"
/>
</label>
<Select
v-model="categoryFilter"
:options="categoryFilterOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<Button v-if="hasFilters" variant="ghost" @click="clearFilters">Filter loeschen</Button>
</div> </div>
</div> </div>
<div class="mt-6 space-y-4"> <div class="p-5">
<div <div class="grid gap-4">
v-for="candidate in filteredCandidates" <article
:key="candidate.id" v-for="candidate in filteredCandidates"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5" :key="candidate.id"
> class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)]"
<div class="grid gap-4 md:grid-cols-2"> >
<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 border-amber-200 bg-amber-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-700">
{{ candidate.platform }}
</span>
<span class="rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-700">
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
</span>
</div>
<h3 class="mt-3 truncate font-[Cormorant_Garamond] text-4xl text-violet-800">{{ candidate.displayName }}</h3>
<p class="mt-1 text-sm font-semibold text-slate-500">{{ candidate.channelSlug }}</p>
</div>
<Button :disabled="candidateSaving === candidate.id" size="sm" @click="saveCandidate(candidate.id)">
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select
v-model="candidateForms[candidate.id].categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Display Name</span>
<input v-model="candidateForms[candidate.id].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="Display Name" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input v-model="candidateForms[candidate.id].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>
<input v-model="candidateForms[candidate.id].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="Twitch, YouTube, ..." />
</label>
</div>
</article>
<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>
<aside class="space-y-4 xl:sticky xl:top-6 xl:self-start">
<Card class="p-6">
<div class="flex items-start gap-4">
<div class="rounded-2xl bg-violet-100 p-3 text-violet-700">
<UserPlus class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
<h2 class="mt-1 font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidat anlegen</h2>
<p class="mt-1 text-sm leading-6 text-slate-500">Erstelle bekannte Kandidaten direkt fuer die richtige Kategorie.</p>
</div>
</div>
<div class="mt-6 space-y-4">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select <Select
v-model="candidateForms[candidate.id].categoryId" v-model="newCandidateForm.categoryId"
:options="categoryOptions" :options="categoryOptions"
option-label="label" option-label="label"
option-value="value" option-value="value"
class="w-full" class="w-full"
/> />
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" /> </label>
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" /> <label class="space-y-2">
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" /> <span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Display Name</span>
</div> <input v-model="newCandidateForm.displayName" type="text" 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="z.B. Jayuhime" />
</label>
<div class="mt-4 flex justify-end"> <label class="space-y-2">
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)"> <span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }} <input v-model="newCandidateForm.channelSlug" type="text" 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="@channel" />
</Button> </label>
</div> <label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<input v-model="newCandidateForm.platform" type="text" 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="Twitch" />
</label>
<Button class="w-full" :disabled="candidateSaving === 'new' || !canCreateCandidate" @click="createCandidate">
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
</Button>
</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"> <p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
Keine Kandidaten passen zum aktuellen Filter. {{ adminMessage }}
</p> </p>
</div> <p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
</Card> {{ adminError }}
</p>
</Card>
<Card class="p-7"> <Card class="p-5">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2> <div class="flex items-center gap-3">
<div class="mt-6 space-y-4"> <Sparkles class="h-5 w-5 text-amber-500" />
<Select <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Workflow</p>
v-model="newCandidateForm.categoryId" </div>
:options="categoryOptions" <div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
option-label="label" <p class="flex gap-3"><Users class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" /> Erst Kandidaten suchen, damit du keine Duplikate anlegst.</p>
option-value="value" <p class="flex gap-3"><Tags class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" /> Kategorie-Chip pruefen, dann nur die noetigen Felder anpassen.</p>
class="w-full" </div>
/> </Card>
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" /> </aside>
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
</Button>
</div>
<p v-if="adminMessage" class="mt-6 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-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
</Card>
</div> </div>
</div> </div>
</template> </template>