Extract season overview/workflow cards and add sponsor seed data

Splits AdminSeasonsView into AdminSeasonOverviewCard and AdminSeasonWorkflowCard
for cleaner separation of concerns. Adds SeedSponsorsBootstrapper with demo
sponsor SVG assets and extends SeedCatalog with sponsor seed entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-29 09:08:56 +02:00
parent 1ab26ec2fd
commit c466348d18
11 changed files with 668 additions and 446 deletions
+25 -446
View File
@@ -1,33 +1,20 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, ref } from 'vue'
import {
AlertTriangle,
CalendarCog,
CheckCircle2,
Clock,
ExternalLink,
Globe2,
History,
LockKeyhole,
Pencil,
PlusCircle,
Trash2,
WandSparkles,
X,
} from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminSeasonCreateModal from '../../components/admin/AdminSeasonCreateModal.vue'
import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModal.vue'
import AdminSeasonOverviewCard from '../../components/admin/AdminSeasonOverviewCard.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import {
createSeasonTimelineRows,
normalizePhaseKey,
resolveAutoPhase,
SEASON_PHASES,
type PhaseKey,
type PhaseRowConfig,
} from '../../components/admin/adminSeasonTimeline'
import AdminSeasonWorkflowCard from '../../components/admin/AdminSeasonWorkflowCard.vue'
import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
@@ -61,14 +48,12 @@ const {
latestSeasonAuditMeta,
canCreate,
phaseGateway,
activatePhase,
activatePublicSeason,
requestPhaseGateway,
requestCompletionGateway,
confirmPhaseGateway,
openCreateModal,
saveSeason,
completeSeason,
createSeason,
openDeleteSeasonModal,
confirmDeleteSeason,
@@ -88,181 +73,11 @@ async function selectSeason(seasonId: number) {
}
}
// ---------- Phase / Timeline ----------
const currentPhaseKey = computed(() => normalizePhaseKey(form.currentPhase))
const autoPhase = computed(() => resolveAutoPhase(form))
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
const autoMismatch = computed(() => Boolean(autoPhase.value && normalizePhaseKey(autoPhase.value) !== currentPhaseKey.value))
const todayLabel = computed(() =>
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
)
const editingPhase = ref<PhaseKey | null>(null)
const timelineError = ref('')
const draft = reactive({ start: '', end: '', showStartsAt: '20:00' })
const phaseWarning = ref<{
action: 'activate' | 'complete'
phase: string
reason: string
detail: string
range: string
} | null>(null)
const timelineRows = computed(() =>
createSeasonTimelineRows(form, currentPhaseKey.value, autoPhase.value ?? '', editingPhase.value).map((row) => ({
...row,
finalLocked: row.key === 'completed' && !row.active,
})),
)
function startEdit(row: PhaseRowConfig) {
if (!row.editable) return
timelineError.value = ''
editingPhase.value = row.key
draft.start = row.start ? form[row.start] : ''
draft.end = row.end ? form[row.end] : ''
draft.showStartsAt = form.showStartsAt || '20:00'
}
function cancelEdit() {
editingPhase.value = null
timelineError.value = ''
}
async function saveEdit(row: PhaseRowConfig) {
timelineError.value = ''
if (!row.start || !row.end) return
if (!draft.start || !draft.end) { timelineError.value = 'Start und Ende ausfüllen.'; return }
if (row.key !== 'show' && draft.start > draft.end) { timelineError.value = 'Startdatum darf nicht nach Enddatum liegen.'; return }
form[row.start] = draft.start
form[row.end] = row.key === 'show' ? draft.start : draft.end
if (row.key === 'show') form.showStartsAt = draft.showStartsAt || '20:00'
const saved = await saveSeason()
if (saved !== false) editingPhase.value = null
}
// ---------- Countdown helpers ----------
function daysUntil(dateStr: string | undefined | null): number | null {
if (!dateStr) return null
const target = new Date(`${dateStr}T00:00:00`)
if (Number.isNaN(target.getTime())) return null
const diff = Math.ceil((target.getTime() - Date.now()) / 86_400_000)
return diff
}
function phaseCountdown(row: (typeof timelineRows.value)[number]): string | null {
if (row.active) {
const endField = SEASON_PHASES.find((p) => p.key === row.key)?.end
if (!endField) return null
const days = daysUntil(form[endField])
if (days === null) return null
if (days < 0) return 'Überfällig'
if (days === 0) return 'Heute endet'
if (days === 1) return 'Noch 1 Tag'
return `Noch ${days} Tage`
}
const startField = SEASON_PHASES.find((p) => p.key === row.key)?.start
if (!startField) return null
const days = daysUntil(form[startField])
if (days === null || days <= 0) return null
return `Startet in ${days} Tagen`
}
function parseLocalDate(value: string | undefined | null) {
if (!value) return null
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
const date = new Date(year, month - 1, day)
date.setHours(0, 0, 0, 0)
return Number.isNaN(date.getTime()) ? null : date
}
function formatLocalDate(value: string | undefined | null) {
const date = parseLocalDate(value)
return date
? date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: 'offen'
}
function todayAtStartOfDay() {
const today = new Date()
today.setHours(0, 0, 0, 0)
return today
}
function phaseWindowWarning(row: (typeof timelineRows.value)[number]) {
if (row.key === 'completed' || !row.start || !row.end) return null
const start = parseLocalDate(form[row.start])
const end = parseLocalDate(form[row.end])
if (!start || !end) {
return {
reason: `Für „${row.title}“ ist kein vollständiges Zeitfenster hinterlegt.`,
detail: 'Bitte prüfe zuerst Start und Ende der Phase oder bestätige bewusst die manuelle Aktivierung.',
}
}
const today = todayAtStartOfDay()
if (today < start) {
return {
reason: `${row.title}“ startet laut Zeitplan erst am ${formatLocalDate(form[row.start])}.`,
detail: 'Wenn du jetzt aktivierst, öffnen Public-API, Countdown und Teilnahme-Gates vor dem geplanten Start.',
}
}
if (today > end) {
return {
reason: `${row.title}“ ist laut Zeitplan seit dem ${formatLocalDate(form[row.end])} beendet.`,
detail: 'Wenn du jetzt aktivierst, springt die öffentliche Award-Phase zurück in ein bereits abgelaufenes Fenster.',
}
}
return null
}
function requestPhaseActivation(row: (typeof timelineRows.value)[number]) {
if (saving.value || !selectedSeasonId.value || row.active || row.finalLocked) return
const warning = phaseWindowWarning(row)
if (warning) {
phaseWarning.value = {
action: 'activate',
phase: row.title,
range: row.dateRange,
reason: warning.reason,
detail: warning.detail,
}
return
}
void requestPhaseGateway(row.title)
}
function requestPhaseActivationByTitle(phase: string) {
const row = timelineRows.value.find((item) => item.title === phase)
if (!row) {
void requestPhaseGateway(phase)
return
}
requestPhaseActivation(row)
}
function requestCompleteSeason() {
if (completing.value || !canCompleteSelectedSeason.value) return
void requestCompletionGateway()
}
async function confirmPhaseWarning() {
const pending = phaseWarning.value
if (!pending) return
phaseWarning.value = null
if (pending.action === 'complete') {
await completeSeason()
return
}
await activatePhase(pending.phase)
}
// ---------- Quick stats ----------
const quickStats = computed(() => {
const d = seasonDetail.value
@@ -342,76 +157,20 @@ const readinessScore = computed(() => {
</div>
</Card>
<!-- Aktives Jahr: Hero + Quick Stats + Actions -->
<Card v-if="selectedSeasonId" class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] px-6 py-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex items-end gap-4">
<strong class="text-5xl font-black leading-none text-slate-950">{{ form.year }}</strong>
<div class="mb-0.5">
<p class="text-lg font-bold leading-snug text-slate-800">{{ form.name || '' }}</p>
<p class="mt-1 text-sm text-slate-500">{{ form.showStreamUrl || 'Kein Stream-Link' }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="rounded-2xl border px-4 py-2 text-sm font-bold"
:class="form.isCurrent
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-100 text-slate-600'"
>
{{ form.isCurrent ? '🟢 Public aktiv' : '⚫ Intern' }}
</span>
<span class="rounded-2xl border border-violet-200 bg-violet-100 px-4 py-2 text-sm font-bold text-violet-800">
{{ form.currentPhase || 'Keine Phase' }}
</span>
</div>
</div>
</div>
<!-- Quick stats -->
<div class="grid grid-cols-2 divide-x divide-y divide-violet-50 border-b border-violet-100 sm:grid-cols-5">
<div
v-for="stat in quickStats"
:key="stat.label"
class="px-5 py-4"
>
<p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="stat.warn ? 'text-amber-600' : 'text-slate-400'">{{ stat.label }}</p>
<strong class="mt-1 block text-2xl" :class="stat.warn ? 'text-amber-700' : 'text-slate-900'">{{ stat.value }}</strong>
</div>
</div>
<!-- Actions footer -->
<div class="flex flex-wrap items-center gap-3 px-5 py-4">
<Button
variant="ghost"
class="gap-1.5 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100"
:disabled="!canDeleteSelectedSeason"
@click="openDeleteSeasonModal"
>
<Trash2 class="h-4 w-4" /> Löschen
</Button>
<Button
variant="ghost"
class="gap-1.5 border border-emerald-100 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
:disabled="saving || form.isCurrent || !canActivatePublic"
@click="activatePublicSeason"
>
<Globe2 class="h-4 w-4" /> {{ saving ? 'Aktiviert …' : 'Public aktivieren' }}
</Button>
<Button
variant="ghost"
class="gap-1.5 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100"
:disabled="completing || !canCompleteSelectedSeason"
@click="requestCompleteSeason"
>
<CheckCircle2 class="h-4 w-4" /> {{ completing ? 'Schließt …' : 'Beenden' }}
</Button>
<Button class="gap-1.5 ml-auto" :disabled="saving" @click="saveSeason">
{{ saving ? 'Speichert ' : 'Speichern' }}
</Button>
</div>
</Card>
<AdminSeasonOverviewCard
v-if="selectedSeasonId"
:form="form"
:saving="saving"
:completing="completing"
:quick-stats="quickStats"
:can-delete-selected-season="canDeleteSelectedSeason"
:can-activate-public="canActivatePublic"
:can-complete-selected-season="canCompleteSelectedSeason"
:open-delete-season-modal="openDeleteSeasonModal"
:activate-public-season="activatePublicSeason"
:request-complete-season="requestCompleteSeason"
:save-season="saveSeason"
/>
<!-- Grunddaten + Readiness -->
<section v-if="selectedSeasonId" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
@@ -559,150 +318,14 @@ const readinessScore = computed(() => {
</Card>
</section>
<!-- Phase & Timeline (kombiniert) -->
<Card v-if="selectedSeasonId" class="overflow-hidden">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] px-6 py-5">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Phase & Timeline</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Phasenwechsel und Zeitplan</h2>
<p class="mt-1.5 max-w-2xl text-sm leading-5 text-slate-500">
Aktive Phase wechseln und Zeitfenster direkt inline bearbeiten. Die Daten steuern Public-API, Gates und Countdown.
</p>
</div>
<!-- Auto-mismatch compact banner -->
<div v-if="autoMismatch" class="flex items-center gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<AlertTriangle class="h-4 w-4 shrink-0 text-amber-700" />
<div>
<p class="text-sm font-bold text-amber-900">Zeitplan schlägt vor: {{ autoPhase }}</p>
</div>
<Button
variant="ghost"
size="sm"
class="ml-2 border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
:disabled="saving || !selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
@click="autoPhase && !autoPhaseIsCompleted && requestPhaseActivationByTitle(autoPhase)"
>
{{ autoPhaseIsCompleted ? 'Über Beenden' : 'Aktivieren' }}
</Button>
</div>
</div>
<p v-if="timelineError" class="border-b border-rose-100 bg-rose-50 px-6 py-3 text-sm font-semibold text-rose-700">{{ timelineError }}</p>
<!-- Phase cards grid -->
<div class="p-5">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<article
v-for="row in timelineRows"
:key="row.key"
class="flex flex-col rounded-[22px] border transition"
:class="row.active
? 'border-violet-300 bg-[linear-gradient(135deg,#ede9fe,#fff)] shadow-[0_16px_40px_rgba(139,108,219,0.16)]'
: row.isEditing
? 'border-violet-200 bg-violet-50/70'
: row.statusLabel === 'Auto'
? 'border-amber-200 bg-amber-50/50'
: 'border-violet-100 bg-white/90 hover:border-violet-200'"
>
<!-- Card header -->
<div class="flex items-center justify-between gap-2 px-4 pt-4">
<span
class="rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.08em]"
:class="row.statusClass"
>
{{ row.statusLabel }}
</span>
<span class="h-2 w-2 rounded-full" :class="row.dotClass" />
</div>
<!-- Card body -->
<div class="flex-1 px-4 pb-2 pt-3">
<h3 class="text-base font-bold text-slate-900">{{ row.title }}</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ row.description }}</p>
<!-- Date display or edit -->
<div class="mt-3">
<template v-if="row.isEditing">
<div class="space-y-2">
<template v-if="row.key === 'show'">
<input v-model="draft.start" type="date" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
<input v-model="draft.showStartsAt" type="time" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
</template>
<template v-else>
<input v-model="draft.start" type="date" placeholder="Start" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
<input v-model="draft.end" type="date" placeholder="Ende" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
</template>
</div>
</template>
<template v-else>
<p class="text-sm font-semibold text-slate-700">{{ row.dateRange }}</p>
<!-- Countdown badge for active/upcoming phases -->
<span
v-if="phaseCountdown(row)"
class="mt-2 inline-flex items-center gap-1.5 rounded-full border border-violet-100 bg-violet-50 px-2.5 py-1 text-[11px] font-semibold"
:class="row.active && phaseCountdown(row) === 'Überfällig' ? 'border-rose-200 bg-rose-50 text-rose-700' : 'text-violet-700'"
>
<Clock class="h-3 w-3" />
{{ phaseCountdown(row) }}
</span>
</template>
</div>
</div>
<!-- Card actions -->
<div class="flex flex-wrap gap-2 px-4 pb-4 pt-2">
<template v-if="row.isEditing">
<Button size="sm" class="flex-1" :disabled="saving" @click="saveEdit(row)">OK</Button>
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-slate-500 hover:bg-violet-50"
@click="cancelEdit"
>
<X class="h-4 w-4" />
</button>
</template>
<template v-else>
<!-- Activate phase button -->
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 rounded-2xl px-3 py-2 text-xs font-bold transition disabled:cursor-not-allowed disabled:opacity-60"
:class="row.active
? 'bg-violet-600 text-white'
: row.finalLocked
? 'border border-slate-200 bg-slate-50 text-slate-500'
: 'border border-violet-200 bg-white text-violet-700 hover:bg-violet-50'"
:disabled="saving || !selectedSeasonId || row.active || row.finalLocked"
@click="requestPhaseActivation(row)"
>
<CheckCircle2 v-if="row.active" class="h-3.5 w-3.5" />
<LockKeyhole v-else-if="row.finalLocked" class="h-3.5 w-3.5" />
<WandSparkles v-else class="h-3.5 w-3.5" />
{{ row.active ? 'Aktiv' : row.finalLocked ? 'Beenden nutzen' : 'Aktivieren' }}
</button>
<!-- Edit dates button -->
<button
v-if="row.editable"
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-violet-600 transition hover:bg-violet-50 disabled:opacity-50"
:disabled="saving || !selectedSeasonId"
title="Datum bearbeiten"
@click="startEdit(row)"
>
<Pencil class="h-3.5 w-3.5" />
</button>
<span v-else class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-100 bg-slate-50">
<LockKeyhole class="h-3.5 w-3.5 text-slate-400" />
</span>
</template>
</div>
</article>
</div>
</div>
<div class="border-t border-violet-100 bg-violet-50/30 px-6 py-3 text-xs leading-5 text-slate-500">
Timeline-Felder steuern Public-API, Vorschau und Teilnahme-Gates. Änderungen werden erst mit Speichern" gespeichert.
</div>
</Card>
<AdminSeasonWorkflowCard
v-if="selectedSeasonId"
:form="form"
:saving="saving"
:selected-season-id="selectedSeasonId"
:save-season="saveSeason"
:request-phase-gateway="requestPhaseGateway"
/>
<!-- Empty state -->
<Card v-else class="px-8 py-16 text-center">
@@ -735,50 +358,6 @@ const readinessScore = computed(() => {
:on-confirm-delete="confirmDeleteSeason"
/>
<Modal
:open="!!phaseWarning"
title="Phase außerhalb des Zeitfensters"
subtitle="Der Zeitplan passt nicht zur gewählten manuellen Umstellung."
@close="phaseWarning = null"
>
<div v-if="phaseWarning" class="space-y-4">
<div class="flex gap-4 rounded-[24px] border border-amber-200 bg-amber-50 p-4 text-amber-900">
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
<div>
<p class="font-bold">{{ phaseWarning.reason }}</p>
<p class="mt-2 text-sm font-semibold leading-6 text-amber-800">{{ phaseWarning.detail }}</p>
</div>
</div>
<div class="grid gap-3 rounded-[22px] border border-violet-100 bg-white/80 p-4 text-sm">
<div class="flex items-center justify-between gap-4">
<span class="font-semibold text-slate-500">Gewählte Phase</span>
<strong class="text-right text-slate-900">{{ phaseWarning.phase }}</strong>
</div>
<div class="flex items-center justify-between gap-4">
<span class="font-semibold text-slate-500">Geplanter Zeitraum</span>
<strong class="text-right text-slate-900">{{ phaseWarning.range }}</strong>
</div>
<div class="flex items-center justify-between gap-4">
<span class="font-semibold text-slate-500">Heute</span>
<strong class="text-right text-slate-900">{{ todayLabel }}</strong>
</div>
</div>
<p class="rounded-2xl border border-violet-100 bg-violet-50/70 px-4 py-3 text-sm font-semibold leading-6 text-slate-600">
Bestätige nur, wenn der Zeitplan bewusst übersteuert werden soll. Nach der Bestätigung wird die Phase sofort gespeichert.
</p>
</div>
<template #footer>
<Button type="button" variant="ghost" :disabled="saving || completing" @click="phaseWarning = null">
Abbrechen
</Button>
<Button type="button" variant="secondary" :disabled="saving || completing" @click="confirmPhaseWarning">
{{ phaseWarning?.action === 'complete' ? 'Trotzdem beenden' : 'Trotzdem aktivieren' }}
</Button>
</template>
</Modal>
<Modal
:open="!!phaseGateway"
:title="phaseGateway?.title || 'Check-Gateway'"