Refine admin workflows and team access
This commit is contained in:
@@ -1,14 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarCog } from '@lucide/vue'
|
||||
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 AdminAwardYearsPanel from '../../components/admin/AdminAwardYearsPanel.vue'
|
||||
import AdminSeasonCreateModal from '../../components/admin/AdminSeasonCreateModal.vue'
|
||||
import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModal.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonPhaseSwitcher from '../../components/admin/AdminSeasonPhaseSwitcher.vue'
|
||||
import AdminSeasonStatusCard from '../../components/admin/AdminSeasonStatusCard.vue'
|
||||
import AdminSeasonTimelineEditor from '../../components/admin/AdminSeasonTimelineEditor.vue'
|
||||
import {
|
||||
createSeasonTimelineRows,
|
||||
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'
|
||||
|
||||
const {
|
||||
store,
|
||||
@@ -24,14 +47,13 @@ const {
|
||||
deleting,
|
||||
seasonDetail,
|
||||
selectedSeasonId,
|
||||
selectedSeason,
|
||||
readinessItems,
|
||||
archiveReadinessIssues,
|
||||
createPublicReadinessIssues,
|
||||
canActivatePublic,
|
||||
phasePresets,
|
||||
canDeleteSelectedSeason,
|
||||
canCompleteSelectedSeason,
|
||||
createPublicReadinessIssues,
|
||||
copySourceOptions,
|
||||
loadingSeasonAudit,
|
||||
latestSeasonAuditSummary,
|
||||
@@ -46,66 +68,541 @@ const {
|
||||
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 editingPhase = ref<PhaseKey | null>(null)
|
||||
const timelineError = ref('')
|
||||
const draft = reactive({ start: '', end: '', showStartsAt: '20:00' })
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
// ---------- 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="Jahr, Phase und öffentliche Sichtbarkeit steuern."
|
||||
description="Award-Jahr wählen, Phase steuern, Timeline pflegen."
|
||||
:icon="CalendarCog"
|
||||
/>
|
||||
|
||||
<section class="space-y-6">
|
||||
<AdminSeasonPhaseSwitcher
|
||||
:form="form"
|
||||
:season-name="seasonDetail.name"
|
||||
:saving="saving"
|
||||
:selected-season-id="selectedSeasonId"
|
||||
:activate-phase="activatePhase"
|
||||
/>
|
||||
<!-- ═══ 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>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<AdminSeasonStatusCard
|
||||
:form="form"
|
||||
:season-name="seasonDetail.name"
|
||||
:saving="saving"
|
||||
:completing="completing"
|
||||
:admin-message="adminMessage"
|
||||
:admin-error="adminError"
|
||||
:readiness-items="readinessItems"
|
||||
:archive-readiness-issues="archiveReadinessIssues"
|
||||
:can-activate-public="canActivatePublic"
|
||||
:loading-season-audit="loadingSeasonAudit"
|
||||
:latest-season-audit-summary="latestSeasonAuditSummary"
|
||||
:latest-season-audit-meta="latestSeasonAuditMeta"
|
||||
:selected-season-id="selectedSeasonId"
|
||||
:can-delete-selected-season="canDeleteSelectedSeason"
|
||||
:can-complete-selected-season="canCompleteSelectedSeason"
|
||||
:selected-season-is-current="selectedSeason?.isCurrent ?? false"
|
||||
:open-delete-season-modal="openDeleteSeasonModal"
|
||||
:activate-public-season="activatePublicSeason"
|
||||
:save-season="saveSeason"
|
||||
:complete-season="completeSeason"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<AdminAwardYearsPanel
|
||||
:seasons="store.adminSeasons"
|
||||
:selected-season-id="selectedSeasonId"
|
||||
:open-create-modal="openCreateModal"
|
||||
:load-season-detail="store.loadAdminSeasonDetail"
|
||||
/>
|
||||
</section>
|
||||
<!-- ═══ 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>
|
||||
|
||||
<AdminSeasonTimelineEditor
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:selected-season-id="selectedSeasonId"
|
||||
:save-season="saveSeason"
|
||||
/>
|
||||
<!-- 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="completeSeason"
|
||||
>
|
||||
<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 && activatePhase(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="activatePhase(row.title)"
|
||||
>
|
||||
<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"
|
||||
|
||||
Reference in New Issue
Block a user