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:
@@ -0,0 +1,17 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="124" fill="url(#bg)"/>
|
||||
<rect x="118" y="152" width="276" height="220" rx="48" fill="#FFF7ED"/>
|
||||
<path d="M188 152C188 114.444 218.444 84 256 84C293.556 84 324 114.444 324 152" stroke="#FFF7ED" stroke-width="22" stroke-linecap="round"/>
|
||||
<path d="M178 222H334" stroke="#FB7185" stroke-width="18" stroke-linecap="round"/>
|
||||
<path d="M178 270H334" stroke="#F59E0B" stroke-width="18" stroke-linecap="round"/>
|
||||
<path d="M178 318H286" stroke="#8B5CF6" stroke-width="18" stroke-linecap="round"/>
|
||||
<circle cx="336" cy="318" r="28" fill="#22C55E"/>
|
||||
<path d="M324 318L334 328L350 308" stroke="#FFF7ED" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="78" y1="58" x2="438" y2="450" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4C1D95"/>
|
||||
<stop offset="0.52" stop-color="#C026D3"/>
|
||||
<stop offset="1" stop-color="#FB7185"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="124" fill="url(#bg)"/>
|
||||
<path d="M182 318C148 318 122 292 122 258C122 228 144 204 172 200C182 158 220 128 266 128C318 128 360 166 366 216C393 222 414 246 414 276C414 312 384 340 348 340H182V318Z" fill="#F8FAFC"/>
|
||||
<rect x="176" y="228" width="160" height="28" rx="14" fill="#2563EB"/>
|
||||
<rect x="176" y="270" width="130" height="24" rx="12" fill="#60A5FA"/>
|
||||
<rect x="176" y="308" width="188" height="24" rx="12" fill="#93C5FD"/>
|
||||
<path d="M342 162L362 188L394 192L372 214L378 246L350 232L322 246L328 214L306 192L338 188L342 162Z" fill="#FDE68A"/>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="78" y1="62" x2="432" y2="450" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#111827"/>
|
||||
<stop offset="0.48" stop-color="#1D4ED8"/>
|
||||
<stop offset="1" stop-color="#38BDF8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 961 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="124" fill="url(#bg)"/>
|
||||
<rect x="64" y="64" width="384" height="384" rx="96" fill="rgba(255,255,255,0.08)" stroke="rgba(255,255,255,0.2)" stroke-width="8"/>
|
||||
<path d="M152 336L256 144L360 336H312L286 286H226L200 336H152ZM245 244H267L256 222L245 244Z" fill="#FFF8ED"/>
|
||||
<circle cx="350" cy="166" r="28" fill="#FFD166"/>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="82" y1="56" x2="430" y2="448" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2A1F5E"/>
|
||||
<stop offset="0.55" stop-color="#5D38B1"/>
|
||||
<stop offset="1" stop-color="#F97362"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 731 B |
@@ -0,0 +1,17 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="124" fill="url(#bg)"/>
|
||||
<path d="M150 150L214 110L232 188L150 150Z" fill="#FFF1F5"/>
|
||||
<path d="M362 150L298 110L280 188L362 150Z" fill="#FFF1F5"/>
|
||||
<rect x="132" y="170" width="248" height="188" rx="94" fill="#FFF1F5"/>
|
||||
<circle cx="220" cy="244" r="22" fill="#EC4899"/>
|
||||
<circle cx="292" cy="244" r="22" fill="#EC4899"/>
|
||||
<path d="M204 304C224.333 324 250.333 334 282 334C306.667 334 327 324 343 304" stroke="#EC4899" stroke-width="22" stroke-linecap="round"/>
|
||||
<path d="M214 150H298" stroke="#FDE047" stroke-width="18" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="66" y1="54" x2="436" y2="452" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0F172A"/>
|
||||
<stop offset="0.52" stop-color="#1D4ED8"/>
|
||||
<stop offset="1" stop-color="#22D3EE"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 962 B |
@@ -0,0 +1,15 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="124" fill="url(#bg)"/>
|
||||
<circle cx="256" cy="256" r="118" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.22)" stroke-width="12"/>
|
||||
<circle cx="256" cy="256" r="42" fill="#FFF7ED"/>
|
||||
<path d="M126 286C152 250 170 228 182 220C194 212 204 224 214 240C224 256 232 274 244 278C256 282 266 274 278 252C290 230 302 214 316 216C330 218 346 238 386 286" stroke="#FDBA74" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M166 174V338" stroke="#FFF7ED" stroke-width="14" stroke-linecap="round"/>
|
||||
<path d="M346 174V338" stroke="#FFF7ED" stroke-width="14" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="78" y1="70" x2="434" y2="442" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#083344"/>
|
||||
<stop offset="0.5" stop-color="#0F766E"/>
|
||||
<stop offset="1" stop-color="#14B8A6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1019 B |
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<Card 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">{{ props.form.year }}</strong>
|
||||
<div class="mb-0.5">
|
||||
<p class="text-lg font-bold leading-snug text-slate-800">{{ props.form.name || '–' }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ props.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="props.form.isCurrent
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 bg-slate-100 text-slate-600'"
|
||||
>
|
||||
{{ props.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">
|
||||
{{ props.form.currentPhase || 'Keine Phase' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 props.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>
|
||||
|
||||
<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="!props.canDeleteSelectedSeason"
|
||||
@click="props.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="props.saving || props.form.isCurrent || !props.canActivatePublic"
|
||||
@click="props.activatePublicSeason"
|
||||
>
|
||||
<Globe2 class="h-4 w-4" /> {{ props.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="props.completing || !props.canCompleteSelectedSeason"
|
||||
@click="props.requestCompleteSeason"
|
||||
>
|
||||
<CheckCircle2 class="h-4 w-4" /> {{ props.completing ? 'Schließt …' : 'Beenden' }}
|
||||
</Button>
|
||||
<Button class="ml-auto gap-1.5" :disabled="props.saving" @click="props.saveSeason">
|
||||
{{ props.saving ? 'Speichert …' : 'Speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Globe2, Trash2 } from '@lucide/vue'
|
||||
|
||||
import type { AdminSeasonForm } from './adminSeasonTypes'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
interface QuickStat {
|
||||
label: string
|
||||
value: number
|
||||
warn?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminSeasonForm
|
||||
saving: boolean
|
||||
completing: boolean
|
||||
quickStats: QuickStat[]
|
||||
canDeleteSelectedSeason: boolean
|
||||
canActivatePublic: boolean
|
||||
canCompleteSelectedSeason: boolean
|
||||
openDeleteSeasonModal: () => void
|
||||
activatePublicSeason: () => Promise<boolean | void> | boolean | void
|
||||
requestCompleteSeason: () => void
|
||||
saveSeason: () => Promise<boolean | void> | boolean | void
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<Card 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>
|
||||
<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="props.saving || !props.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>
|
||||
|
||||
<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'"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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="props.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>
|
||||
<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="props.saving || !props.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>
|
||||
<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="props.saving || !props.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>
|
||||
|
||||
<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="props.saving" @click="phaseWarning = null">
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" :disabled="props.saving" @click="confirmPhaseWarning">
|
||||
Trotzdem aktivieren
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
LockKeyhole,
|
||||
Pencil,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import {
|
||||
createSeasonTimelineRows,
|
||||
normalizePhaseKey,
|
||||
resolveAutoPhase,
|
||||
SEASON_PHASES,
|
||||
type PhaseKey,
|
||||
type PhaseRowConfig,
|
||||
} from './adminSeasonTimeline'
|
||||
import type { AdminSeasonForm } from './adminSeasonTypes'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
type TimelineRow = ReturnType<typeof createSeasonTimelineRows>[number] & {
|
||||
finalLocked: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminSeasonForm
|
||||
saving: boolean
|
||||
selectedSeasonId: number | null
|
||||
saveSeason: () => Promise<boolean | void> | boolean | void
|
||||
requestPhaseGateway: (phase: string) => Promise<void> | void
|
||||
}>()
|
||||
|
||||
const currentPhaseKey = computed(() => normalizePhaseKey(props.form.currentPhase))
|
||||
const autoPhase = computed(() => resolveAutoPhase(props.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<{
|
||||
phase: string
|
||||
reason: string
|
||||
detail: string
|
||||
range: string
|
||||
} | null>(null)
|
||||
|
||||
const timelineRows = computed<TimelineRow[]>(() =>
|
||||
createSeasonTimelineRows(props.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 ? props.form[row.start] : ''
|
||||
draft.end = row.end ? props.form[row.end] : ''
|
||||
draft.showStartsAt = props.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
|
||||
}
|
||||
props.form[row.start] = draft.start
|
||||
props.form[row.end] = row.key === 'show' ? draft.start : draft.end
|
||||
if (row.key === 'show') props.form.showStartsAt = draft.showStartsAt || '20:00'
|
||||
const saved = await props.saveSeason()
|
||||
if (saved !== false) editingPhase.value = null
|
||||
}
|
||||
|
||||
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: TimelineRow): string | null {
|
||||
if (row.active) {
|
||||
const endField = SEASON_PHASES.find((phase) => phase.key === row.key)?.end
|
||||
if (!endField) return null
|
||||
const days = daysUntil(props.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((phase) => phase.key === row.key)?.start
|
||||
if (!startField) return null
|
||||
const days = daysUntil(props.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: TimelineRow) {
|
||||
if (row.key === 'completed' || !row.start || !row.end) return null
|
||||
|
||||
const start = parseLocalDate(props.form[row.start])
|
||||
const end = parseLocalDate(props.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(props.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(props.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: TimelineRow) {
|
||||
if (props.saving || !props.selectedSeasonId || row.active || row.finalLocked) return
|
||||
|
||||
const warning = phaseWindowWarning(row)
|
||||
if (warning) {
|
||||
phaseWarning.value = {
|
||||
phase: row.title,
|
||||
range: row.dateRange,
|
||||
reason: warning.reason,
|
||||
detail: warning.detail,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
void props.requestPhaseGateway(row.title)
|
||||
}
|
||||
|
||||
function requestPhaseActivationByTitle(phase: string) {
|
||||
const row = timelineRows.value.find((item) => item.title === phase)
|
||||
if (!row) {
|
||||
void props.requestPhaseGateway(phase)
|
||||
return
|
||||
}
|
||||
|
||||
requestPhaseActivation(row)
|
||||
}
|
||||
|
||||
async function confirmPhaseWarning() {
|
||||
const pending = phaseWarning.value
|
||||
if (!pending) return
|
||||
phaseWarning.value = null
|
||||
await props.requestPhaseGateway(pending.phase)
|
||||
}
|
||||
</script>
|
||||
@@ -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'"
|
||||
|
||||
Reference in New Issue
Block a user