Harden audit privacy and admin modals
This commit is contained in:
@@ -22,6 +22,7 @@ import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModa
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import {
|
||||
createSeasonTimelineRows,
|
||||
hasShowDayPassed,
|
||||
normalizePhaseKey,
|
||||
resolveAutoPhase,
|
||||
SEASON_PHASES,
|
||||
@@ -32,6 +33,7 @@ import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonMana
|
||||
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,
|
||||
@@ -88,10 +90,20 @@ 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) => ({
|
||||
@@ -150,6 +162,118 @@ function phaseCountdown(row: (typeof timelineRows.value)[number]): string | 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
|
||||
@@ -290,7 +414,7 @@ const readinessScore = computed(() => {
|
||||
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"
|
||||
@click="requestCompleteSeason"
|
||||
>
|
||||
<CheckCircle2 class="h-4 w-4" /> {{ completing ? 'Schließt …' : 'Beenden' }}
|
||||
</Button>
|
||||
@@ -467,7 +591,7 @@ const readinessScore = computed(() => {
|
||||
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)"
|
||||
@click="autoPhase && !autoPhaseIsCompleted && requestPhaseActivationByTitle(autoPhase)"
|
||||
>
|
||||
{{ autoPhaseIsCompleted ? 'Über Beenden' : 'Aktivieren' }}
|
||||
</Button>
|
||||
@@ -558,8 +682,8 @@ const readinessScore = computed(() => {
|
||||
: 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)"
|
||||
: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" />
|
||||
@@ -621,4 +745,48 @@ const readinessScore = computed(() => {
|
||||
: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>
|
||||
|
||||
Reference in New Issue
Block a user