Files
vtuber-awards/frontend/src/views/admin/AdminSeasonsView.vue
T
AzuTear 9393d433fa
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 56s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m5s
Harden audit privacy and admin modals
2026-06-26 20:47:07 +02:00

793 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, reactive, 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 AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import {
createSeasonTimelineRows,
hasShowDayPassed,
normalizePhaseKey,
resolveAutoPhase,
SEASON_PHASES,
type PhaseKey,
type PhaseRowConfig,
} from '../../components/admin/adminSeasonTimeline'
import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
const {
store,
form,
createForm,
saving,
adminMessage,
adminError,
createModalOpen,
creating,
completing,
seasonToDelete,
deleting,
seasonDetail,
selectedSeasonId,
readinessItems,
archiveReadinessIssues,
canActivatePublic,
phasePresets,
canDeleteSelectedSeason,
canCompleteSelectedSeason,
createPublicReadinessIssues,
copySourceOptions,
loadingSeasonAudit,
latestSeasonAuditSummary,
latestSeasonAuditMeta,
canCreate,
activatePhase,
activatePublicSeason,
openCreateModal,
saveSeason,
completeSeason,
createSeason,
openDeleteSeasonModal,
confirmDeleteSeason,
} = useAdminSeasonManager()
watchAdminToast(adminMessage, adminError)
// ---------- Season switching ----------
const switchingSeasonId = ref<number | null>(null)
async function selectSeason(seasonId: number) {
if (seasonId === selectedSeasonId.value || switchingSeasonId.value) return
switchingSeasonId.value = seasonId
try {
await store.loadAdminSeasonDetail(seasonId)
} finally {
switchingSeasonId.value = null
}
}
// ---------- 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 activatePhase(row.title)
}
function requestPhaseActivationByTitle(phase: string) {
const row = timelineRows.value.find((item) => item.title === phase)
if (!row) {
void activatePhase(phase)
return
}
requestPhaseActivation(row)
}
function requestCompleteSeason() {
if (completing.value || !canCompleteSelectedSeason.value) return
if (!hasShowDayPassed(form)) {
phaseWarning.value = {
action: 'complete',
phase: 'Abgeschlossen',
range: form.showDate ? `nach ${formatLocalDate(form.showDate)}` : 'nach der Award Show',
reason: form.showDate
? `Die Award Show ist laut Zeitplan erst am ${formatLocalDate(form.showDate)} bzw. noch nicht vorbei.`
: 'Für die Award Show ist noch kein Datum hinterlegt.',
detail: 'Wenn du jetzt beendest, werden Public-Aktionen gesperrt und das Jahr als abgeschlossen behandelt.',
}
return
}
void completeSeason()
}
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
const pendingClips = d.clipSubmissions.filter((c) => c.status === 'pending').length
return [
{ label: 'Kategorien', value: d.categories.length },
{ label: 'Kandidaten', value: d.candidates.length },
{ label: 'Nominierungen', value: d.pendingNominations.length, warn: d.pendingNominations.length > 0 },
{ label: 'Clips offen', value: pendingClips, warn: pendingClips > 0 },
{ label: 'Gewinner', value: d.results.length },
]
})
// ---------- Readiness score ----------
const readinessScore = computed(() => {
const done = readinessItems.value.filter((i) => i.complete).length
return { done, total: readinessItems.value.length }
})
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Jahre"
description="Award-Jahr wählen, Phase steuern, Timeline pflegen."
:icon="CalendarCog"
/>
<!-- ═══ Jahr-Auswahl ═══ -->
<Card class="overflow-hidden">
<div class="flex items-center justify-between gap-3 border-b border-violet-100 px-5 py-3.5">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Award-Jahre</p>
<Button variant="ghost" size="sm" class="gap-1.5 border border-violet-100 text-violet-700 hover:bg-violet-50" @click="openCreateModal">
<PlusCircle class="h-4 w-4" />
Neu
</Button>
</div>
<div class="flex gap-2 overflow-x-auto px-5 py-4">
<button
v-for="season in store.adminSeasons"
:key="season.id"
type="button"
class="flex shrink-0 flex-col gap-1.5 rounded-[20px] border px-4 py-3 text-left transition"
:class="season.id === selectedSeasonId
? 'border-violet-300 bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] shadow-[0_12px_28px_rgba(139,108,219,0.18)]'
: 'border-violet-100 bg-white/80 hover:border-violet-200 hover:bg-violet-50/60'"
@click="selectSeason(season.id)"
>
<div class="flex items-center gap-2">
<strong class="text-2xl leading-none" :class="season.id === selectedSeasonId ? 'text-violet-900' : 'text-slate-700'">
{{ season.year }}
</strong>
<span
class="rounded-full border px-2 py-0.5 text-[10px] font-bold"
:class="season.isCurrent
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-500'"
>
{{ season.isCurrent ? 'Public' : 'Intern' }}
</span>
</div>
<p class="max-w-[180px] truncate text-xs text-slate-500">{{ season.name }}</p>
<span class="rounded-full border border-violet-100 bg-white/80 px-2.5 py-0.5 text-[11px] font-semibold text-violet-700">
{{ season.currentPhase }}
</span>
</button>
<button
v-if="store.adminSeasons.length === 0"
type="button"
class="flex shrink-0 items-center gap-2 rounded-[20px] border border-dashed border-violet-200 px-6 py-4 text-sm font-semibold text-violet-500 hover:bg-violet-50"
@click="openCreateModal"
>
<PlusCircle class="h-4 w-4" />
Erstes Jahr anlegen
</button>
</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>
<!-- ═══ Grunddaten + Readiness ═══ -->
<section v-if="selectedSeasonId" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<!-- Grunddaten -->
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Grunddaten</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Stammdaten bearbeiten</h2>
<div class="mt-5 space-y-4">
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr</span>
<input
v-model.number="form.year"
type="number"
placeholder="2027"
: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>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
v-model="form.name"
type="text"
placeholder="VTuber Star Awards 2027"
: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>
<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="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">
<input v-model="form.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="saving" />
<span>
<span class="block font-semibold text-slate-800">Community-only</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">Voting und Teilnahme über die Community.</span>
</span>
</label>
<label
class="flex cursor-pointer gap-3 rounded-[22px] border p-4 transition"
:class="form.isCurrent
? 'border-emerald-200 bg-emerald-50/60 hover:bg-emerald-50'
: 'border-violet-100 bg-violet-50/50 hover:bg-violet-50 has-disabled:opacity-70'"
>
<input
v-model="form.isCurrent"
type="checkbox"
class="mt-1 h-4 w-4 shrink-0 accent-violet-600"
:disabled="saving || (!form.isCurrent && !canActivatePublic)"
/>
<span>
<span class="block font-semibold text-slate-800">Public-Kontext</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">
{{ form.isCurrent ? 'Aktiv auf der Landingpage.' : canActivatePublic ? 'Kann aktiviert werden.' : 'Readiness-Blocker auflösen.' }}
</span>
</span>
</label>
</div>
<!-- Audit entry -->
<div class="flex items-start gap-3 rounded-[22px] border border-violet-100 bg-white p-4">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<History class="h-5 w-5" />
</span>
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Letzte Admin-Aktion</p>
<p class="mt-1 text-sm font-semibold leading-5 text-slate-800">
{{ loadingSeasonAudit ? 'Lädt Audit …' : latestSeasonAuditSummary }}
</p>
<p class="mt-0.5 text-xs text-slate-500">{{ latestSeasonAuditMeta }}</p>
</div>
</div>
</div>
</Card>
<!-- Readiness -->
<Card class="p-6">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Checkliste</h2>
</div>
<div
class="flex items-center gap-2 rounded-2xl border px-3 py-2 text-xs font-bold"
:class="canActivatePublic ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700'"
>
<span class="h-2 w-2 rounded-full" :class="canActivatePublic ? 'bg-emerald-500' : 'bg-rose-500'" />
{{ readinessScore.done }}/{{ readinessScore.total }} bereit
</div>
</div>
<!-- Readiness progress bar -->
<div class="mt-4">
<div class="h-2 overflow-hidden rounded-full bg-slate-100">
<div
class="h-full rounded-full transition-all duration-500"
:class="canActivatePublic ? 'bg-emerald-500' : 'bg-violet-500'"
:style="{ width: `${(readinessScore.done / readinessScore.total) * 100}%` }"
/>
</div>
</div>
<div class="mt-4 grid gap-2">
<RouterLink
v-for="item in readinessItems"
:key="item.label"
:to="item.to"
class="group flex items-start gap-3 rounded-2xl border px-4 py-3 transition hover:shadow-sm"
:class="item.complete
? 'border-emerald-100 bg-emerald-50/60 hover:bg-emerald-50'
: item.blocking
? 'border-rose-200 bg-rose-50/60 hover:bg-rose-50'
: 'border-amber-100 bg-amber-50/60 hover:bg-amber-50'"
>
<CheckCircle2 v-if="item.complete" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<AlertTriangle v-else class="mt-0.5 h-4 w-4 shrink-0" :class="item.blocking ? 'text-rose-600' : 'text-amber-600'" />
<span class="min-w-0 flex-1">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-800">
{{ item.label }}
<span v-if="item.blocking && !item.complete" class="rounded-full bg-rose-100 px-2 py-0.5 text-[10px] font-bold text-rose-700">Blocker</span>
</span>
<span class="mt-0.5 block text-xs leading-5 text-slate-500">{{ item.note }}</span>
</span>
<ExternalLink class="mt-1 h-3.5 w-3.5 shrink-0 text-slate-400 opacity-0 transition group-hover:opacity-100" />
</RouterLink>
</div>
<div v-if="archiveReadinessIssues.length > 0" class="mt-4 rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700">
<strong class="block text-rose-800">Abschluss blockiert.</strong>
{{ archiveReadinessIssues.join(' ') }}
</div>
</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>
<!-- Empty state -->
<Card v-else class="px-8 py-16 text-center">
<CalendarCog class="mx-auto h-8 w-8 text-violet-300" />
<p class="mt-4 text-sm text-slate-500">Noch keine Award-Jahre geladen.</p>
<Button class="mx-auto mt-5 gap-2" @click="openCreateModal">
<PlusCircle class="h-4 w-4" />
Erstes Jahr anlegen
</Button>
</Card>
</div>
<!-- Modals (unverändert) -->
<AdminSeasonCreateModal
:open="createModalOpen"
:create-form="createForm"
:creating="creating"
:can-create="canCreate"
:copy-source-options="copySourceOptions"
:create-public-readiness-issues="createPublicReadinessIssues"
:phase-presets="phasePresets"
:on-close="() => { createModalOpen = false }"
:on-create="createSeason"
/>
<AdminSeasonDeleteModal
:season-to-delete="seasonToDelete"
:deleting="deleting"
:on-close="() => { seasonToDelete = null }"
: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>
</template>