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
+40
View File
@@ -7,6 +7,7 @@ internal sealed record CandidateSeed(string CategorySlug, string DisplayName, st
internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform); internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
internal sealed record SiteFaqSeed(string Question, string Answer); internal sealed record SiteFaqSeed(string Question, string Answer);
internal sealed record SiteSocialSeed(string Label, string Platform, string Url, string Icon); internal sealed record SiteSocialSeed(string Label, string Platform, string Url, string Icon);
internal sealed record SponsorSeed(string Name, string WebsiteUrl, string LogoUrl, string Description, string Tier, int SortOrder);
internal static class SeedCatalog internal static class SeedCatalog
{ {
@@ -141,4 +142,43 @@ Partner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen gena
new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"), new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"),
new(2023, "best-collab-duo-rising-star", "Yuki & Melo", "@yukistern", "Twitch"), new(2023, "best-collab-duo-rising-star", "Yuki & Melo", "@yukistern", "Twitch"),
]; ];
internal static readonly SponsorSeed[] DemoSponsorSeeds =
[
new(
"HoshiForge Studio",
"https://hoshiforge.example",
"/demo/sponsors/hoshiforge-studio.svg",
"Branding-, Overlay- und Debuet-Visuals fuer VTuber-Projekte und Community-Events.",
"Presenting Sponsor",
10),
new(
"NekoPixel Energy",
"https://nekopixel.example",
"/demo/sponsors/nekopixel-energy.svg",
"Community-fokussierter Drink-Partner fuer lange Showabende, Watchpartys und Creator-Collabs.",
"Gold Partner",
20),
new(
"PrismLoop Audio",
"https://prismloop.example",
"/demo/sponsors/prismloop-audio.svg",
"Audio-Tools, Intro-Packs und Stream-Sounddesign fuer Live-Shows und Highlight-Clips.",
"Gold Partner",
30),
new(
"CloudBeacon Hosting",
"https://cloudbeacon.example",
"/demo/sponsors/cloudbeacon-hosting.svg",
"Skalierbares Hosting fuer Voting, Landingpages und Event-Traffic rund um Showtage.",
"Tech Partner",
40),
new(
"ChibiCanvas Market",
"https://chibicanvas.example",
"/demo/sponsors/chibicanvas-market.svg",
"Merch-, Sticker- und Artist-Marketplace mit Fokus auf VTuber, Emotes und Fanartikel.",
"Community Partner",
50),
];
} }
+1
View File
@@ -22,6 +22,7 @@ public static partial class SeedDataBootstrapper
if (seasons.TryGetValue(2026, out var currentSeason)) if (seasons.TryGetValue(2026, out var currentSeason))
{ {
await EnsureCandidatesAsync(db, currentSeason, SeedCatalog.CurrentCandidateSeeds); await EnsureCandidatesAsync(db, currentSeason, SeedCatalog.CurrentCandidateSeeds);
await EnsureSponsorsAsync(db, currentSeason);
await EnsureSeedOperationalDataAsync(db, currentSeason); await EnsureSeedOperationalDataAsync(db, currentSeason);
} }
+40
View File
@@ -0,0 +1,40 @@
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureSponsorsAsync(AwardsDbContext db, Season season)
{
var existingSponsors = await db.Sponsors
.Where(item => item.SeasonId == season.Id)
.ToListAsync();
foreach (var seed in SeedCatalog.DemoSponsorSeeds)
{
var sponsor = existingSponsors.FirstOrDefault(item =>
string.Equals(item.Name, seed.Name, StringComparison.OrdinalIgnoreCase))
?? new Sponsor
{
SeasonId = season.Id,
CreatedAt = DateTimeOffset.UtcNow,
};
sponsor.Name = seed.Name;
sponsor.WebsiteUrl = seed.WebsiteUrl;
sponsor.LogoUrl = seed.LogoUrl;
sponsor.Description = seed.Description;
sponsor.Tier = seed.Tier;
sponsor.SortOrder = seed.SortOrder;
sponsor.IsVisible = true;
sponsor.UpdatedAt = DateTimeOffset.UtcNow;
if (sponsor.Id == 0)
{
db.Sponsors.Add(sponsor);
existingSponsors.Add(sponsor);
}
}
}
}
@@ -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>
+25 -446
View File
@@ -1,33 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from 'vue' import { computed, ref } from 'vue'
import { import {
AlertTriangle, AlertTriangle,
CalendarCog, CalendarCog,
CheckCircle2, CheckCircle2,
Clock,
ExternalLink, ExternalLink,
Globe2,
History, History,
LockKeyhole,
Pencil,
PlusCircle, PlusCircle,
Trash2,
WandSparkles,
X,
} from '@lucide/vue' } from '@lucide/vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import AdminSeasonCreateModal from '../../components/admin/AdminSeasonCreateModal.vue' import AdminSeasonCreateModal from '../../components/admin/AdminSeasonCreateModal.vue'
import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModal.vue' import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModal.vue'
import AdminSeasonOverviewCard from '../../components/admin/AdminSeasonOverviewCard.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import { import AdminSeasonWorkflowCard from '../../components/admin/AdminSeasonWorkflowCard.vue'
createSeasonTimelineRows,
normalizePhaseKey,
resolveAutoPhase,
SEASON_PHASES,
type PhaseKey,
type PhaseRowConfig,
} from '../../components/admin/adminSeasonTimeline'
import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonManager' import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonManager'
import { watchAdminToast } from '../../composables/useAdminToast' import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue' import Button from '../../components/ui/Button.vue'
@@ -61,14 +48,12 @@ const {
latestSeasonAuditMeta, latestSeasonAuditMeta,
canCreate, canCreate,
phaseGateway, phaseGateway,
activatePhase,
activatePublicSeason, activatePublicSeason,
requestPhaseGateway, requestPhaseGateway,
requestCompletionGateway, requestCompletionGateway,
confirmPhaseGateway, confirmPhaseGateway,
openCreateModal, openCreateModal,
saveSeason, saveSeason,
completeSeason,
createSeason, createSeason,
openDeleteSeasonModal, openDeleteSeasonModal,
confirmDeleteSeason, 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() { function requestCompleteSeason() {
if (completing.value || !canCompleteSelectedSeason.value) return if (completing.value || !canCompleteSelectedSeason.value) return
void requestCompletionGateway() 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 ---------- // ---------- Quick stats ----------
const quickStats = computed(() => { const quickStats = computed(() => {
const d = seasonDetail.value const d = seasonDetail.value
@@ -342,76 +157,20 @@ const readinessScore = computed(() => {
</div> </div>
</Card> </Card>
<!-- Aktives Jahr: Hero + Quick Stats + Actions --> <AdminSeasonOverviewCard
<Card v-if="selectedSeasonId" class="overflow-hidden"> v-if="selectedSeasonId"
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] px-6 py-5"> :form="form"
<div class="flex flex-wrap items-start justify-between gap-4"> :saving="saving"
<div class="flex items-end gap-4"> :completing="completing"
<strong class="text-5xl font-black leading-none text-slate-950">{{ form.year }}</strong> :quick-stats="quickStats"
<div class="mb-0.5"> :can-delete-selected-season="canDeleteSelectedSeason"
<p class="text-lg font-bold leading-snug text-slate-800">{{ form.name || '' }}</p> :can-activate-public="canActivatePublic"
<p class="mt-1 text-sm text-slate-500">{{ form.showStreamUrl || 'Kein Stream-Link' }}</p> :can-complete-selected-season="canCompleteSelectedSeason"
</div> :open-delete-season-modal="openDeleteSeasonModal"
</div> :activate-public-season="activatePublicSeason"
<div class="flex items-center gap-2"> :request-complete-season="requestCompleteSeason"
<span :save-season="saveSeason"
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 --> <!-- Grunddaten + Readiness -->
<section v-if="selectedSeasonId" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]"> <section v-if="selectedSeasonId" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
@@ -559,150 +318,14 @@ const readinessScore = computed(() => {
</Card> </Card>
</section> </section>
<!-- Phase & Timeline (kombiniert) --> <AdminSeasonWorkflowCard
<Card v-if="selectedSeasonId" class="overflow-hidden"> v-if="selectedSeasonId"
<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"> :form="form"
<div> :saving="saving"
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Phase & Timeline</p> :selected-season-id="selectedSeasonId"
<h2 class="mt-1 text-xl font-bold text-slate-900">Phasenwechsel und Zeitplan</h2> :save-season="saveSeason"
<p class="mt-1.5 max-w-2xl text-sm leading-5 text-slate-500"> :request-phase-gateway="requestPhaseGateway"
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 --> <!-- Empty state -->
<Card v-else class="px-8 py-16 text-center"> <Card v-else class="px-8 py-16 text-center">
@@ -735,50 +358,6 @@ const readinessScore = computed(() => {
:on-confirm-delete="confirmDeleteSeason" :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 <Modal
:open="!!phaseGateway" :open="!!phaseGateway"
:title="phaseGateway?.title || 'Check-Gateway'" :title="phaseGateway?.title || 'Check-Gateway'"