Update release notes and deploy workspace
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { BarChart3, CheckCircle2, ExternalLink, TrendingUp } from '@lucide/vue'
|
||||
import { BarChart3, ExternalLink, TrendingUp } from '@lucide/vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AdminCategoryHealthCard from '../../components/admin/AdminCategoryHealthCard.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import { useAdminAnalyticsManager } from '../../components/admin/useAdminAnalyticsManager'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import NativeSelect from '../../components/ui/NativeSelect.vue'
|
||||
|
||||
const {
|
||||
categoryHealth,
|
||||
categoryGroups,
|
||||
metricCards,
|
||||
readinessCards,
|
||||
readinessScope,
|
||||
insightCards,
|
||||
attentionItems,
|
||||
topCategoriesEnriched,
|
||||
@@ -272,91 +273,17 @@ watch([healthFilter, groupFilter], ([nextHealth, nextGroup]) => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Category health -->
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Status aller Kategorien</h2>
|
||||
</div>
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<NativeSelect
|
||||
v-model="groupFilter"
|
||||
class="w-44"
|
||||
:options="groupOptions.map((group) => ({ label: group, value: group }))"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="opt in healthStatusOptions"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
class="flex h-9 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition"
|
||||
:class="healthFilter === opt.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="healthFilter = opt.key"
|
||||
>
|
||||
<span v-if="opt.dot" class="h-2 w-2 rounded-full" :class="opt.dot" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredHealth.length > 0" class="divide-y divide-violet-50">
|
||||
<RouterLink
|
||||
v-for="category in filteredHealth"
|
||||
:key="category.id"
|
||||
:to="`/admin/categories?group=${encodeURIComponent(category.groupName || 'Ohne Gruppe')}`"
|
||||
class="grid items-center gap-3 px-5 py-3.5 transition hover:bg-violet-50/50 sm:grid-cols-[minmax(0,1fr)_80px_80px_100px_140px]"
|
||||
>
|
||||
<!-- Name + group -->
|
||||
<span class="min-w-0">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :class="category.statusDot" />
|
||||
<strong class="truncate text-sm text-slate-900">{{ category.name }}</strong>
|
||||
</span>
|
||||
<span class="mt-0.5 block truncate text-xs text-slate-500 pl-4">{{ category.groupName || 'Ohne Gruppe' }}</span>
|
||||
</span>
|
||||
|
||||
<!-- Candidates -->
|
||||
<span class="text-sm text-slate-500">
|
||||
<strong class="block text-slate-900">{{ category.candidates }}</strong>
|
||||
Kandidaten
|
||||
</span>
|
||||
|
||||
<!-- Reviews -->
|
||||
<span class="text-sm" :class="category.reviews > 0 ? 'text-amber-600 font-semibold' : 'text-slate-500'">
|
||||
<strong class="block" :class="category.reviews > 0 ? 'text-amber-700' : 'text-slate-900'">{{ category.reviews }}</strong>
|
||||
Reviews
|
||||
</span>
|
||||
|
||||
<!-- Votes + pct -->
|
||||
<span class="text-sm text-slate-500">
|
||||
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
|
||||
<span class="text-violet-600 font-semibold">{{ category.votePct }}%</span>
|
||||
</span>
|
||||
|
||||
<!-- Status badge -->
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
|
||||
<CheckCircle2 v-if="category.status === 'Gewinner gesetzt'" class="h-3 w-3" />
|
||||
{{ category.status }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="px-5 py-12 text-center text-sm text-slate-500">
|
||||
Keine Kategorien für diesen Filter.
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 border-t border-violet-100 bg-emerald-50/60 px-5 py-3 text-sm text-emerald-800">
|
||||
<CheckCircle2 class="h-4 w-4 shrink-0 text-emerald-500" />
|
||||
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
|
||||
<RouterLink to="/admin/categories" class="ml-auto flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800">
|
||||
Kategorien bearbeiten <ExternalLink class="h-3 w-3" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Card>
|
||||
<AdminCategoryHealthCard
|
||||
:filtered-health="filteredHealth"
|
||||
:health-summary="healthSummary"
|
||||
:group-options="groupOptions"
|
||||
:group-filter="groupFilter"
|
||||
:health-filter="healthFilter"
|
||||
:health-status-options="healthStatusOptions"
|
||||
:candidate-warnings-active="readinessScope.candidateWarningsActive"
|
||||
:review-warnings-active="readinessScope.reviewWarningsActive"
|
||||
@update:group-filter="groupFilter = $event"
|
||||
@update:health-filter="healthFilter = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { FileText, Handshake, MessageCircleQuestion, Mic2, ScrollText, Send, Share2, ShieldCheck } from '@lucide/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { FileText, Handshake, MessageCircleQuestion, Mic2, RadioTower, ScrollText, Send, Share2, ShieldCheck, Sparkles } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AdminContentAwardsSection from '../../components/admin/AdminContentAwardsSection.vue'
|
||||
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
|
||||
import AdminContentFaqPreviewModal from '../../components/admin/AdminContentFaqPreviewModal.vue'
|
||||
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
|
||||
@@ -10,15 +12,18 @@ import AdminContentPageSection from '../../components/admin/AdminContentPageSect
|
||||
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
|
||||
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
|
||||
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
|
||||
import AdminContentStreamBannerSection from '../../components/admin/AdminContentStreamBannerSection.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import Modal from '../../components/ui/Modal.vue'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
|
||||
|
||||
type ContentEditorKey = 'imprint' | 'contact' | 'socials' | 'faq' | 'privacy' | 'showacts' | 'sponsors'
|
||||
type ContentEditorKey = 'awards' | 'streamBanner' | 'imprint' | 'contact' | 'socials' | 'faq' | 'privacy' | 'showacts' | 'sponsors'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
form,
|
||||
@@ -52,6 +57,22 @@ const faqPreviewOpen = ref(false)
|
||||
const activeContentEditor = ref<ContentEditorKey | null>(null)
|
||||
|
||||
const contentEditors = computed(() => [
|
||||
{
|
||||
key: 'awards' as const,
|
||||
eyebrow: 'Landingpage',
|
||||
title: 'Awards-Bereich',
|
||||
description: 'Titel und Beschreibung für Die Awards und Unterkategorien pflegen.',
|
||||
metric: form.awardsSectionTitle.trim() && form.subcategoriesSectionTitle.trim() ? 'Texte gepflegt' : 'Texte prüfen',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
key: 'streamBanner' as const,
|
||||
eyebrow: 'Landingpage',
|
||||
title: 'Finale-Banner',
|
||||
description: 'Titel, Text, Button und Abschluss-Inhalt für den Countdown-Banner pflegen.',
|
||||
metric: form.streamBannerUseCompletedContent ? 'Abschluss aktiv' : 'Countdown aktiv',
|
||||
icon: RadioTower,
|
||||
},
|
||||
{
|
||||
key: 'imprint' as const,
|
||||
eyebrow: 'Rechtliches',
|
||||
@@ -112,8 +133,26 @@ const contentEditors = computed(() => [
|
||||
|
||||
const currentContentEditor = computed(() => contentEditors.value.find((item) => item.key === activeContentEditor.value) ?? null)
|
||||
|
||||
watch(
|
||||
() => route.query.editor,
|
||||
(editor) => {
|
||||
const resolved = Array.isArray(editor) ? editor[0] : editor
|
||||
activeContentEditor.value = contentEditors.value.some((item) => item.key === resolved)
|
||||
? resolved as ContentEditorKey
|
||||
: null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function openContentEditor(key: ContentEditorKey) {
|
||||
activeContentEditor.value = key
|
||||
const nextQuery = { ...route.query, editor: key } as Record<string, string>
|
||||
void router.replace({ query: nextQuery })
|
||||
}
|
||||
|
||||
function closeContentEditor() {
|
||||
const nextQuery = { ...route.query } as Record<string, string>
|
||||
delete nextQuery.editor
|
||||
void router.replace({ query: nextQuery })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -159,10 +198,22 @@ function openContentEditor(key: ContentEditorKey) {
|
||||
:title="currentContentEditor?.title"
|
||||
:subtitle="currentContentEditor?.description"
|
||||
size="xl"
|
||||
@close="activeContentEditor = null"
|
||||
@close="closeContentEditor"
|
||||
>
|
||||
<AdminContentAwardsSection
|
||||
v-if="activeContentEditor === 'awards'"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:save-site-settings="saveSiteSettings"
|
||||
/>
|
||||
<AdminContentStreamBannerSection
|
||||
v-else-if="activeContentEditor === 'streamBanner'"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:save-site-settings="saveSiteSettings"
|
||||
/>
|
||||
<AdminContentPageSection
|
||||
v-if="activeContentEditor === 'imprint'"
|
||||
v-else-if="activeContentEditor === 'imprint'"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
content-key="imprintContent"
|
||||
|
||||
@@ -4,7 +4,10 @@ import { LayoutDashboard } from '@lucide/vue'
|
||||
import AdminDashboardActivitySection from '../../components/admin/AdminDashboardActivitySection.vue'
|
||||
import AdminDashboardChecksSection from '../../components/admin/AdminDashboardChecksSection.vue'
|
||||
import AdminDashboardHeroSection from '../../components/admin/AdminDashboardHeroSection.vue'
|
||||
import AdminDashboardPrioritySection from '../../components/admin/AdminDashboardPrioritySection.vue'
|
||||
import AdminDashboardPublicHealthCard from '../../components/admin/AdminDashboardPublicHealthCard.vue'
|
||||
import AdminDashboardQueueCard from '../../components/admin/AdminDashboardQueueCard.vue'
|
||||
import AdminDashboardReadinessCard from '../../components/admin/AdminDashboardReadinessCard.vue'
|
||||
import AdminDashboardTimelineStrip from '../../components/admin/AdminDashboardTimelineStrip.vue'
|
||||
import AdminDashboardTopCategoriesSection from '../../components/admin/AdminDashboardTopCategoriesSection.vue'
|
||||
import AdminDashboardYearTotalsSection from '../../components/admin/AdminDashboardYearTotalsSection.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
@@ -16,15 +19,25 @@ const {
|
||||
activities,
|
||||
topCategories,
|
||||
metricCards,
|
||||
openReviewCount,
|
||||
openRiskCount,
|
||||
liveSummary,
|
||||
liveStatusBadge,
|
||||
maxCategoryVotes,
|
||||
totalCategoryVotes,
|
||||
liveStatusTone,
|
||||
maxCategoryValue,
|
||||
topCategoryTitle,
|
||||
topCategoryTotalLabel,
|
||||
yearTotals,
|
||||
priorityActions,
|
||||
queueItems,
|
||||
operationChecks,
|
||||
timelinePhases,
|
||||
activeCountdown,
|
||||
phaseMismatch,
|
||||
phaseMismatchLabel,
|
||||
readinessItems,
|
||||
readinessScore,
|
||||
publicHealthItems,
|
||||
adminGreeting,
|
||||
canViewAuditLog,
|
||||
canOpenYears,
|
||||
} = useAdminDashboardOverview()
|
||||
</script>
|
||||
|
||||
@@ -32,25 +45,42 @@ const {
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Dashboard"
|
||||
:description="`${adminGreeting} · Saisonlage, Queues und Public-Readiness für das gewählte Jahr.`"
|
||||
:icon="LayoutDashboard"
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<AdminDashboardTimelineStrip
|
||||
:timeline-phases="timelinePhases"
|
||||
:active-countdown="activeCountdown"
|
||||
:phase-mismatch="phaseMismatch"
|
||||
:phase-mismatch-label="phaseMismatchLabel"
|
||||
:can-open-years="canOpenYears"
|
||||
/>
|
||||
|
||||
<section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<AdminDashboardHeroSection
|
||||
:metric-cards="metricCards"
|
||||
:live-summary="liveSummary"
|
||||
:live-status-badge="liveStatusBadge"
|
||||
:open-risk-count="openRiskCount"
|
||||
:open-review-count="openReviewCount"
|
||||
:live-status-tone="liveStatusTone"
|
||||
/>
|
||||
|
||||
<AdminDashboardPrioritySection :priority-actions="priorityActions" />
|
||||
<AdminDashboardQueueCard :queue-items="queueItems" />
|
||||
</section>
|
||||
|
||||
<AdminDashboardChecksSection :operation-checks="operationChecks" />
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<AdminDashboardReadinessCard
|
||||
:readiness-items="readinessItems"
|
||||
:readiness-score="readinessScore"
|
||||
/>
|
||||
|
||||
<AdminDashboardPublicHealthCard :public-health-items="publicHealthItems" />
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||
<AdminDashboardYearTotalsSection
|
||||
:year="store.adminSeasonDetail.year"
|
||||
@@ -58,12 +88,16 @@ const {
|
||||
/>
|
||||
|
||||
<AdminDashboardTopCategoriesSection
|
||||
:total-category-votes="totalCategoryVotes"
|
||||
:max-category-votes="maxCategoryVotes"
|
||||
:max-category-value="maxCategoryValue"
|
||||
:top-category-title="topCategoryTitle"
|
||||
:top-category-total-label="topCategoryTotalLabel"
|
||||
:top-categories="topCategories"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AdminDashboardActivitySection :activities="activities" />
|
||||
<AdminDashboardActivitySection
|
||||
:activities="activities"
|
||||
:can-view-audit-log="canViewAuditLog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ShieldCheck,
|
||||
Tags,
|
||||
Trophy,
|
||||
Vote,
|
||||
UserCog,
|
||||
Users,
|
||||
Wrench,
|
||||
@@ -40,15 +41,23 @@ const totalClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.le
|
||||
const adminWorkspaceLoading = ref(false)
|
||||
const adminWorkspaceLoaded = ref(false)
|
||||
const mobileNavOpen = ref(false)
|
||||
const seasonWorkspacePermissions = ['dashboard', 'years', 'nominations', 'categories', 'candidates', 'clips', 'analytics', 'voting', 'winners']
|
||||
|
||||
useBodyScrollLock(() => mobileNavOpen.value)
|
||||
|
||||
const canLoadSeasonWorkspace = computed(() =>
|
||||
authStore.canManageAdminWorkspace
|
||||
|| seasonWorkspacePermissions.some((permission) => authStore.hasPermission(permission)),
|
||||
)
|
||||
|
||||
const fullNavGroups = computed(() => [
|
||||
{
|
||||
label: 'Betrieb',
|
||||
items: [
|
||||
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, permission: 'dashboard', badge: () => null },
|
||||
{ label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, permission: 'nominations', badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
|
||||
{ label: 'Voting', to: '/admin/voting', description: 'Stimmenlage und Gewinner-Vorbereitung', icon: Vote, permission: 'voting', badge: () => `${store.adminSeasonDetail.votingWorkspace.summary.problemSubcategories}` },
|
||||
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, permission: 'winners', badge: () => `${store.adminSeasonDetail.results.length}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -86,7 +95,6 @@ const fullNavGroups = computed(() => [
|
||||
label: 'Auswertung',
|
||||
items: [
|
||||
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, permission: 'analytics', badge: () => null },
|
||||
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, permission: 'winners', badge: () => `${store.adminSeasonDetail.results.length}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -126,7 +134,7 @@ async function ensureAdminWorkspace() {
|
||||
|
||||
adminWorkspaceLoading.value = true
|
||||
try {
|
||||
if (authStore.canManageAdminWorkspace) {
|
||||
if (canLoadSeasonWorkspace.value) {
|
||||
await store.initializeAdminWorkspace()
|
||||
} else if (authStore.hasPermission('content') || authStore.hasPermission('settings')) {
|
||||
await store.loadAdminContentWorkspace()
|
||||
@@ -138,7 +146,7 @@ async function ensureAdminWorkspace() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [authStore.hydrated, authStore.canAccessAdmin, authStore.canManageAdminWorkspace, route.path] as const,
|
||||
() => [authStore.hydrated, authStore.canAccessAdmin, canLoadSeasonWorkspace.value, route.path] as const,
|
||||
() => {
|
||||
void ensureAdminWorkspace()
|
||||
},
|
||||
|
||||
@@ -203,16 +203,24 @@ const readinessScore = computed(() => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-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"
|
||||
placeholder="https://twitch.tv/jayuhime"
|
||||
:disabled="saving"
|
||||
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:bg-slate-50 disabled:text-slate-400"
|
||||
/>
|
||||
</label>
|
||||
<div class="rounded-[22px] border border-violet-100 bg-violet-50/60 p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Finale-Banner</p>
|
||||
<h3 class="mt-1 text-base font-bold text-slate-900">Live-Button im Landingpage-Content pflegen</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Der Link für den Finale-Button wird zentral im Landingpage-Modul gepflegt.
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/admin/content?editor=streamBanner"
|
||||
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold text-violet-700 transition hover:bg-violet-100"
|
||||
>
|
||||
Finale-Banner öffnen
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="flex cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:opacity-70">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, KeyRound, LockKeyhole, Minus, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
|
||||
import { Check, KeyRound, LockKeyhole, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminTeamRolePermissionsModal from '../../components/admin/AdminTeamRolePermissionsModal.vue'
|
||||
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
|
||||
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
@@ -43,6 +44,7 @@ const {
|
||||
members,
|
||||
roles,
|
||||
permissions,
|
||||
permissionGroups,
|
||||
memberForm,
|
||||
selectedMember,
|
||||
pendingPasswordChanges,
|
||||
@@ -72,7 +74,6 @@ const anyTeamModalOpen = computed(() =>
|
||||
passwordModalOpen.value
|
||||
|| createMemberModalOpen.value
|
||||
|| editMemberModalOpen.value
|
||||
|| rolePermissionsModalOpen.value
|
||||
|| Boolean(memberPendingDeletion.value),
|
||||
)
|
||||
|
||||
@@ -81,6 +82,7 @@ useBodyScrollLock(() => anyTeamModalOpen.value)
|
||||
const summaryItems = computed(() => [
|
||||
{ label: 'Mitglieder', value: members.value.length, note: `${onlineMembers.value} online` },
|
||||
{ label: 'Rollen', value: roles.value.length, note: 'Owner, Creator und Team' },
|
||||
{ label: 'Berechtigungen', value: permissions.value.length, note: `${permissionGroups.value.length} Bereiche` },
|
||||
{ label: 'PW-Wechsel', value: pendingPasswordChanges.value, note: 'temporäre Passwörter offen' },
|
||||
])
|
||||
|
||||
@@ -112,14 +114,6 @@ function memberOnlineStatus(member: AdminTeamMember) {
|
||||
return member.isOnline ? 'Online' : 'Offline'
|
||||
}
|
||||
|
||||
function rolePermissionLocked(roleKey: string) {
|
||||
return roleKey === 'owner' || roleKey === 'creator'
|
||||
}
|
||||
|
||||
function rolePermissionCount(roleKey: string) {
|
||||
return permissions.value.filter((permission) => roleHasPermission(roleKey, permission.key)).length
|
||||
}
|
||||
|
||||
function isOwnTeamAccount(member: AdminTeamMember) {
|
||||
const teamLogin = authStore.session?.teamLogin?.toLowerCase()
|
||||
const boundTwitchUserId = authStore.session?.boundTwitchUserId?.toLowerCase()
|
||||
@@ -219,7 +213,7 @@ async function changeOwnPassword() {
|
||||
:icon="Users"
|
||||
/>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card
|
||||
v-for="item in summaryItems"
|
||||
:key="item.label"
|
||||
@@ -383,86 +377,18 @@ async function changeOwnPassword() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="rolePermissionsModalOpen"
|
||||
class="fixed inset-0 z-[305] flex items-center justify-center bg-slate-950/50 p-3 backdrop-blur-sm md:p-6"
|
||||
@click.self="rolePermissionsModalOpen = false"
|
||||
>
|
||||
<Card class="flex max-h-[88vh] w-full max-w-6xl flex-col overflow-hidden rounded-[30px] bg-white p-0 shadow-[0_34px_100px_rgba(39,28,72,0.28)]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-violet-100 bg-white px-5 py-4 md:px-6">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-50 text-violet-600 ring-1 ring-violet-100">
|
||||
<ShieldCheck class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
|
||||
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-950">Berechtigungen einstellen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" @click="rolePermissionsModalOpen = false">Schließen</Button>
|
||||
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto bg-slate-50/70 p-3 md:p-5">
|
||||
<table class="w-full min-w-[1040px] overflow-hidden rounded-2xl border border-violet-100 bg-white text-sm shadow-sm">
|
||||
<thead class="sticky top-0 z-20 bg-violet-50 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500 shadow-[0_1px_0_rgba(221,214,254,0.85)]">
|
||||
<tr>
|
||||
<th class="sticky left-0 z-30 w-[340px] bg-violet-50 px-5 py-4">Bereich</th>
|
||||
<th v-for="role in roles" :key="role.key" class="min-w-[112px] px-3 py-4 text-center">
|
||||
<span class="block text-[11px] leading-4 text-violet-600">{{ role.label }}</span>
|
||||
<span class="mt-1 block text-[10px] font-extrabold tracking-normal text-slate-400">
|
||||
{{ rolePermissionCount(role.key) }}/{{ permissions.length }}
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-violet-100 bg-white">
|
||||
<tr v-for="permission in permissions" :key="permission.key" class="transition hover:bg-violet-50/35">
|
||||
<td class="sticky left-0 z-10 w-[340px] bg-white px-5 py-4 shadow-[1px_0_0_rgba(237,233,254,0.95)]">
|
||||
<span class="block text-base font-bold leading-5 text-slate-950">{{ permission.label }}</span>
|
||||
<span class="mt-1 block max-w-[300px] text-xs font-semibold leading-5 text-slate-500">{{ permission.description }}</span>
|
||||
</td>
|
||||
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="relative mx-auto grid h-9 w-9 place-items-center rounded-xl ring-1 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
:class="[
|
||||
roleHasPermission(role.key, permission.key)
|
||||
? rolePermissionLocked(role.key)
|
||||
? 'bg-slate-100 text-slate-400 ring-slate-200'
|
||||
: 'bg-violet-600 text-white shadow-lg shadow-violet-500/20 ring-violet-500 hover:bg-violet-500'
|
||||
: rolePermissionLocked(role.key)
|
||||
? 'bg-slate-50 text-slate-300 ring-slate-200'
|
||||
: 'bg-white text-slate-300 ring-slate-300 hover:text-violet-500 hover:ring-violet-300',
|
||||
]"
|
||||
:disabled="rolePermissionLocked(role.key)"
|
||||
:aria-label="`${role.label}: ${permission.label}`"
|
||||
:aria-pressed="roleHasPermission(role.key, permission.key)"
|
||||
@click="setRolePermission(role.key, permission.key, !roleHasPermission(role.key, permission.key))"
|
||||
>
|
||||
<Check v-if="roleHasPermission(role.key, permission.key)" class="h-5 w-5" :stroke-width="3" />
|
||||
<Minus v-else class="h-4 w-4" :stroke-width="3" />
|
||||
<span
|
||||
v-if="rolePermissionLocked(role.key) && roleHasPermission(role.key, permission.key)"
|
||||
class="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-white text-slate-400 ring-1 ring-slate-200"
|
||||
>
|
||||
<LockKeyhole class="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Teleport>
|
||||
<AdminTeamRolePermissionsModal
|
||||
:open="rolePermissionsModalOpen"
|
||||
:saving="saving"
|
||||
:has-role-changes="hasRoleChanges"
|
||||
:roles="roles"
|
||||
:permissions="permissions"
|
||||
:permission-groups="permissionGroups"
|
||||
:role-has-permission="roleHasPermission"
|
||||
:set-role-permission="setRolePermission"
|
||||
:save-role-permissions="saveRolePermissions"
|
||||
@close="rolePermissionsModalOpen = false"
|
||||
/>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Filter, Vote } from '@lucide/vue'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import AdminVotingLeaderboardModal from '../../components/admin/AdminVotingLeaderboardModal.vue'
|
||||
import AdminVotingTreeWorkspace from '../../components/admin/AdminVotingTreeWorkspace.vue'
|
||||
import { type AdminVotingWorkspaceRow, useAdminVotingManager } from '../../components/admin/useAdminVotingManager'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
|
||||
const {
|
||||
problemOnly,
|
||||
query,
|
||||
statusFilter,
|
||||
statusFilters,
|
||||
summaryCards,
|
||||
votingGroups,
|
||||
selectedGroupName,
|
||||
focusedCategoryId,
|
||||
clearFilters,
|
||||
selectGroup,
|
||||
focusCategory,
|
||||
} = useAdminVotingManager()
|
||||
|
||||
const leaderboardCategory = ref<AdminVotingWorkspaceRow | null>(null)
|
||||
const compactSummaryCards = computed(() => summaryCards.value.slice(0, 4))
|
||||
|
||||
function openLeaderboard(category: AdminVotingWorkspaceRow) {
|
||||
focusCategory(category.categoryId)
|
||||
leaderboardCategory.value = category
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Voting"
|
||||
description="Operative Stimmenlage, Unterkategorie-Status und Gewinner-Vorbereitung in einer Arbeitsflaeche."
|
||||
:icon="Vote"
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card v-for="card in compactSummaryCards" :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="p-5">
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(260px,0.9fr)_minmax(0,1.1fr)_auto] xl:items-center">
|
||||
<label class="relative block">
|
||||
<Filter class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
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="Hauptkategorie, Unterkategorie oder Kandidat suchen"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="option in statusFilters"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="statusFilter === option.value ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="rounded-full bg-white/70 px-2 py-0.5 text-[11px]">{{ option.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||
<Button
|
||||
:variant="problemOnly ? 'default' : 'ghost'"
|
||||
class="shrink-0"
|
||||
@click="problemOnly = !problemOnly"
|
||||
>
|
||||
{{ problemOnly ? 'Nur Sonderfaelle aktiv' : 'Nur Sonderfaelle' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="query || statusFilter !== 'all' || problemOnly"
|
||||
variant="ghost"
|
||||
class="shrink-0"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Filter reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<section>
|
||||
<AdminVotingTreeWorkspace
|
||||
:groups="votingGroups"
|
||||
:selected-group-name="selectedGroupName"
|
||||
:focused-category-id="focusedCategoryId"
|
||||
@select-group="selectGroup"
|
||||
@select-category="focusCategory"
|
||||
@open-leaderboard="openLeaderboard"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AdminVotingLeaderboardModal
|
||||
:category="leaderboardCategory"
|
||||
@close="leaderboardCategory = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,31 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { Award, CheckCircle2, Filter, Trash2, Trophy } from '@lucide/vue'
|
||||
import { AlertTriangle, Clock3, EyeOff, Filter, Trophy, UploadCloud } from '@lucide/vue'
|
||||
|
||||
import AdminWinnersTreeWorkspace from '../../components/admin/AdminWinnersTreeWorkspace.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import { useAdminWinnersManager } from '../../components/admin/useAdminWinnersManager'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import NativeSelect from '../../components/ui/NativeSelect.vue'
|
||||
|
||||
const {
|
||||
adminError,
|
||||
adminMessage,
|
||||
completionPct,
|
||||
clearFilters,
|
||||
deletingResultId,
|
||||
focusedCategoryId,
|
||||
publicationState,
|
||||
publishingWinners,
|
||||
query,
|
||||
savingResultForCategory,
|
||||
selectedGroupName,
|
||||
selectGroup,
|
||||
focusCategory,
|
||||
statusFilter,
|
||||
statusFilters,
|
||||
summaryCards,
|
||||
visibleResultRows,
|
||||
winnerGroups,
|
||||
winnerSelections,
|
||||
unpublishingWinners,
|
||||
clearWinner,
|
||||
publishWinners,
|
||||
saveWinner,
|
||||
workflowRuleSummary,
|
||||
selectedWinnerIdentitySummary,
|
||||
selectedWinnerClipPreview,
|
||||
unpublishWinners,
|
||||
winnerRuleNoticeFor,
|
||||
} = useAdminWinnersManager()
|
||||
|
||||
@@ -36,12 +43,76 @@ watchAdminToast(adminMessage, adminError)
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Gewinner"
|
||||
description="Finale Gewinner je Kategorie setzen, aktualisieren und fuer Archiv sowie Public-Ansicht freigeben."
|
||||
description="Finale Gewinner pro Unterkategorie setzen, Guards pruefen und Landingpage-Freigabe vorbereiten."
|
||||
:icon="Trophy"
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<Card class="p-5">
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-bold uppercase tracking-[0.12em]" :class="publicationState.statusTone">
|
||||
<UploadCloud v-if="!publicationState.isPublished" class="h-4 w-4" />
|
||||
<Trophy v-else class="h-4 w-4" />
|
||||
{{ publicationState.statusLabel }}
|
||||
</span>
|
||||
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1.5 text-xs font-semibold text-violet-700">
|
||||
Phase: {{ publicationState.phaseLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-xl font-bold text-slate-900">Landingpage-Freigabe</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Gewinner bleiben intern, bis dieses Award-Jahr veröffentlicht wird. Nach der Freigabe erscheinen sie auf der Landingpage; der bisherige Landingpage-Jahrgang wandert ins Archiv.
|
||||
</p>
|
||||
<p class="mt-3 text-sm font-semibold text-slate-600">
|
||||
{{ publicationState.publishedAtLabel }}
|
||||
<span v-if="publicationState.isPublished" class="text-slate-400">· {{ publicationState.publishedByLabel }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="publicationState.issues.length > 0" class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="issue in publicationState.issues"
|
||||
:key="issue.key"
|
||||
class="rounded-2xl border px-4 py-3"
|
||||
:class="issue.isWarning ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-violet-100 bg-violet-50 text-violet-800'"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertTriangle v-if="issue.isWarning" class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<Clock3 v-else class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-bold">{{ issue.label }}</p>
|
||||
<p class="mt-1 text-xs leading-5 opacity-80">{{ issue.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 xl:justify-end">
|
||||
<Button
|
||||
:disabled="publicationState.isPublished || !publicationState.canPublish || publishingWinners"
|
||||
class="gap-2"
|
||||
@click="publishWinners"
|
||||
>
|
||||
<UploadCloud class="h-4 w-4" />
|
||||
{{ publishingWinners ? 'Veröffentlicht ...' : publicationState.isPublished ? 'Veröffentlicht' : 'Veröffentlichen' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="publicationState.isPublished"
|
||||
variant="secondary"
|
||||
class="gap-2"
|
||||
:disabled="unpublishingWinners"
|
||||
@click="unpublishWinners"
|
||||
>
|
||||
<EyeOff class="h-4 w-4" />
|
||||
{{ unpublishingWinners ? 'Nimmt zurück ...' : 'Zurücknehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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">
|
||||
@@ -54,220 +125,66 @@ watchAdminToast(adminMessage, adminError)
|
||||
<component :is="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="typeof card.progress === 'number'" class="mt-4">
|
||||
<div class="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: `${card.progress}%` }" />
|
||||
</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 class="mt-4 flex flex-wrap gap-2">
|
||||
<span
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold"
|
||||
:class="workflowRuleSummary.maxWinnerPlacements?.enabled ? 'border-violet-200 bg-violet-50 text-violet-800' : 'border-slate-200 bg-slate-50 text-slate-600'"
|
||||
>
|
||||
{{ workflowRuleSummary.maxWinnerPlacements?.enabled
|
||||
? `Gewinnerlimit aktiv: ${workflowRuleSummary.maxWinnerPlacements.limit}x pro Person`
|
||||
: 'Gewinnerlimit aus' }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold"
|
||||
:class="workflowRuleSummary.winnerRequiresClip?.enabled ? 'border-amber-200 bg-amber-50 text-amber-800' : 'border-slate-200 bg-slate-50 text-slate-600'"
|
||||
>
|
||||
{{ workflowRuleSummary.winnerRequiresClip?.enabled
|
||||
? `Clip-Pflicht ${workflowRuleSummary.winnerRequiresClip.mode === 'warn' ? 'als Warnung' : 'als Blocker'}`
|
||||
: 'Clip-Pflicht aus' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
<section class="grid gap-4 xl:grid-cols-[minmax(260px,0.9fr)_minmax(0,1.1fr)_auto] xl:items-center">
|
||||
<label class="relative block">
|
||||
<Filter class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
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="Hauptkategorie, Unterkategorie oder Gewinner suchen"
|
||||
>
|
||||
<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>
|
||||
</label>
|
||||
|
||||
<div class="min-w-0 space-y-2">
|
||||
<NativeSelect
|
||||
v-model="winnerSelections[row.category.id]"
|
||||
:disabled="row.isEmpty"
|
||||
:options="[
|
||||
{ label: 'Bitte Gewinner waehlen', value: '' },
|
||||
...row.candidates.map((candidate) => ({
|
||||
label: `${candidate.displayName} · ${candidate.channelSlug} · ${candidate.platform} · ${candidate.clipCompilationUrl ? 'Clip gepflegt' : 'kein Clip'}`,
|
||||
value: `${candidate.id}`,
|
||||
})),
|
||||
]"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="winnerRuleNoticeFor(row.category.id)"
|
||||
class="rounded-2xl border px-3 py-2 text-sm font-semibold"
|
||||
:class="winnerRuleNoticeFor(row.category.id)?.mode === 'block'
|
||||
? 'border-rose-100 bg-rose-50 text-rose-700'
|
||||
: 'border-amber-100 bg-amber-50 text-amber-700'"
|
||||
>
|
||||
{{ winnerRuleNoticeFor(row.category.id)?.mode === 'block' ? 'Blockiert' : 'Warnung' }}:
|
||||
{{ winnerRuleNoticeFor(row.category.id)?.message }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="selectedWinnerIdentitySummary(row.category.id)"
|
||||
class="grid gap-2 sm:grid-cols-4"
|
||||
>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Kandidaturen</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.appearances }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Gewinner</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.winnerPlacements }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Viewer-Nominierungen</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.nominationTally }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Clip</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.hasClip ? 'vorhanden' : 'fehlt' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedWinnerClipPreview(row.category.id)"
|
||||
class="rounded-[22px] border border-violet-100 bg-violet-50/40 p-3"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Winner Preview</p>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-semibold text-violet-700">
|
||||
{{ selectedWinnerClipPreview(row.category.id)?.clipPlatform }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
v-if="selectedWinnerClipPreview(row.category.id)?.clipEmbedUrl"
|
||||
:src="selectedWinnerClipPreview(row.category.id)?.clipEmbedUrl ?? undefined"
|
||||
:title="selectedWinnerClipPreview(row.category.id)?.clipEmbedTitle"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
class="w-full rounded-2xl border-0 bg-slate-950"
|
||||
style="aspect-ratio:16/9;"
|
||||
/>
|
||||
|
||||
<a
|
||||
v-else-if="selectedWinnerClipPreview(row.category.id)?.clipUrl"
|
||||
:href="selectedWinnerClipPreview(row.category.id)?.clipUrl ?? undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex aspect-[16/9] items-center justify-center rounded-2xl border border-violet-100 bg-white px-4 text-center text-sm font-semibold text-violet-700 hover:bg-violet-100/60"
|
||||
>
|
||||
{{ selectedWinnerClipPreview(row.category.id)?.clipTitle }}
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex aspect-[16/9] items-center justify-center rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 text-center text-sm font-semibold text-amber-700"
|
||||
>
|
||||
Noch kein Clip-Link gepflegt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
:disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id] || winnerRuleNoticeFor(row.category.id)?.mode === 'block'"
|
||||
@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 class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="option in statusFilters"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border bg-white/90 px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="statusFilter === option.value ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="rounded-full bg-white/70 px-2 py-0.5 text-[11px]">{{ option.count }}</span>
|
||||
</button>
|
||||
</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 class="flex items-center justify-end">
|
||||
<Button
|
||||
v-if="query || statusFilter !== 'all'"
|
||||
variant="ghost"
|
||||
class="shrink-0"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Filter reset
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<AdminWinnersTreeWorkspace
|
||||
:groups="winnerGroups"
|
||||
:selected-group-name="selectedGroupName"
|
||||
:focused-category-id="focusedCategoryId"
|
||||
:winner-selections="winnerSelections"
|
||||
:saving-result-for-category="savingResultForCategory"
|
||||
:deleting-result-id="deletingResultId"
|
||||
:selected-winner-identity-summary="selectedWinnerIdentitySummary"
|
||||
:winner-rule-notice-for="winnerRuleNoticeFor"
|
||||
@select-group="selectGroup"
|
||||
@select-category="focusCategory"
|
||||
@update:winner-selection="(categoryId: number, value: string) => { winnerSelections[categoryId] = value }"
|
||||
@save="saveWinner"
|
||||
@clear="clearWinner"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user