170 lines
7.8 KiB
Vue
170 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
import { Award, CheckCircle2, Filter, Trash2, Trophy } from '@lucide/vue'
|
|
|
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
|
import { useAdminWinnersManager } from '../../components/admin/useAdminWinnersManager'
|
|
import Button from '../../components/ui/Button.vue'
|
|
import Card from '../../components/ui/Card.vue'
|
|
|
|
const {
|
|
adminError,
|
|
adminMessage,
|
|
completionPct,
|
|
deletingResultId,
|
|
query,
|
|
savingResultForCategory,
|
|
statusFilter,
|
|
statusFilters,
|
|
summaryCards,
|
|
visibleResultRows,
|
|
winnerSelections,
|
|
clearWinner,
|
|
saveWinner,
|
|
} = useAdminWinnersManager()
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6">
|
|
<AdminPageHeader
|
|
eyebrow="Gewinner"
|
|
description="Finale Gewinner je Kategorie setzen, aktualisieren und fuer Archiv sowie Public-Ansicht freigeben."
|
|
:icon="Trophy"
|
|
/>
|
|
|
|
<AdminSeasonToolbar />
|
|
|
|
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
<Card v-for="card in summaryCards" :key="card.label" class="p-4">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">{{ card.label }}</p>
|
|
<strong class="mt-2 block text-2xl text-slate-950">{{ card.value }}</strong>
|
|
<p class="mt-1 text-sm leading-5 text-slate-500">{{ card.note }}</p>
|
|
</div>
|
|
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-xl border" :class="card.tone">
|
|
<component :is="card.icon" class="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</section>
|
|
|
|
<Card class="overflow-hidden">
|
|
<div class="border-b border-violet-100 p-5">
|
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-center">
|
|
<div class="min-w-0">
|
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Winner Lock-In</p>
|
|
<h2 class="mt-1 text-xl font-bold text-slate-900">Gewinner freigeben</h2>
|
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
|
Pro Kategorie kann genau ein Gewinner gesetzt werden. Offene Reviews werden sichtbar markiert, blockieren die technische Freigabe aber nicht.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
|
|
<span>Fortschritt</span>
|
|
<span>{{ completionPct }}%</span>
|
|
</div>
|
|
<div class="mt-2 h-3 overflow-hidden rounded-full bg-violet-100">
|
|
<div class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#22c55e)] transition-all" :style="{ width: `${completionPct}%` }" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
|
<label class="relative block">
|
|
<Filter class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
|
<input
|
|
v-model="query"
|
|
type="search"
|
|
placeholder="Kategorie, Gewinner oder Kandidat suchen"
|
|
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-10 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
|
>
|
|
</label>
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
v-for="option in statusFilters"
|
|
:key="option.value"
|
|
type="button"
|
|
class="inline-flex h-10 items-center gap-2 rounded-xl border px-3 text-sm font-semibold transition"
|
|
:class="statusFilter === option.value ? 'border-violet-200 bg-violet-600 text-white shadow-lg shadow-violet-500/20' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
|
@click="statusFilter = option.value"
|
|
>
|
|
{{ option.label }}
|
|
<span class="rounded-full bg-white/25 px-2 py-0.5 text-xs">{{ option.count }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
|
|
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
|
|
|
|
<div v-if="visibleResultRows.length" class="divide-y divide-violet-50">
|
|
<article
|
|
v-for="row in visibleResultRows"
|
|
:key="row.category.id"
|
|
class="grid gap-4 px-5 py-4 2xl:grid-cols-[minmax(0,1fr)_420px_auto] 2xl:items-center"
|
|
>
|
|
<div class="min-w-0">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<p class="truncate font-semibold text-slate-900">{{ row.category.name }}</p>
|
|
<span v-if="row.existing" class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">
|
|
<CheckCircle2 class="h-3.5 w-3.5" />
|
|
gesetzt
|
|
</span>
|
|
<span v-if="row.hasPendingReviews" class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
|
|
{{ row.openReviews }} Reviews offen
|
|
</span>
|
|
<span v-if="row.isEmpty" class="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700">
|
|
keine Kandidaten
|
|
</span>
|
|
</div>
|
|
<p class="mt-1 text-sm text-slate-500">
|
|
{{ row.category.groupName }} · {{ row.candidates.length }} Kandidaten · {{ row.approvedClips }} Clips freigegeben
|
|
</p>
|
|
<p v-if="row.existing" class="mt-2 inline-flex items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">
|
|
<Award class="h-4 w-4" />
|
|
Aktuell: {{ row.existing.candidateDisplayName }} · {{ row.existing.candidatePlatform }}
|
|
</p>
|
|
</div>
|
|
|
|
<select
|
|
v-model="winnerSelections[row.category.id]"
|
|
:disabled="row.isEmpty"
|
|
class="h-12 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
|
|
>
|
|
<option value="">Bitte Gewinner waehlen</option>
|
|
<option v-for="candidate in row.candidates" :key="candidate.id" :value="`${candidate.id}`">
|
|
{{ candidate.displayName }} · {{ candidate.channelSlug }} · {{ candidate.platform }}
|
|
</option>
|
|
</select>
|
|
|
|
<div class="flex flex-wrap justify-end gap-2">
|
|
<Button :disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id]" @click="saveWinner(row.category.id)">
|
|
{{ savingResultForCategory === row.category.id ? 'Speichert ...' : row.existing ? 'Aktualisieren' : 'Setzen' }}
|
|
</Button>
|
|
<Button
|
|
v-if="row.existing"
|
|
variant="secondary"
|
|
class="gap-1.5"
|
|
:disabled="deletingResultId === row.existing.id"
|
|
@click="clearWinner(row.existing.id)"
|
|
>
|
|
<Trash2 class="h-4 w-4" />
|
|
{{ deletingResultId === row.existing.id ? 'Entfernt ...' : 'Entfernen' }}
|
|
</Button>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<div v-else class="p-8 text-center">
|
|
<Trophy class="mx-auto h-8 w-8 text-violet-300" />
|
|
<p class="mt-3 font-semibold text-slate-900">Keine Kategorien fuer diese Filter.</p>
|
|
<p class="mt-1 text-sm text-slate-500">Passe Suche oder Statusfilter an.</p>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</template>
|