Files
vtuber-awards/frontend/src/components/admin/useAdminWinnersManager.ts
T

172 lines
6.5 KiB
TypeScript

import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
const allFilter = 'all'
export function useAdminWinnersManager() {
const store = useAwardsStore()
const savingResultForCategory = ref<number | null>(null)
const deletingResultId = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const query = ref('')
const statusFilter = ref(allFilter)
const winnerSelections = reactive<Record<number, string>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
const resultRows = computed(() =>
seasonDetail.value.categories
.map((category) => {
const candidates = seasonDetail.value.candidates
.filter((candidate) => candidate.categoryId === category.id)
.sort((a, b) => a.displayName.localeCompare(b.displayName))
const existing = resultMap.value.get(category.id) ?? null
const openReviews = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
const approvedClips = seasonDetail.value.clipSubmissions.filter((clip) => clip.categoryId === category.id && clip.status === 'approved').length
return {
category,
candidates,
existing,
openReviews,
approvedClips,
hasPendingReviews: openReviews > 0,
isEmpty: candidates.length === 0,
isComplete: Boolean(existing),
}
})
.sort((a, b) => a.category.sortOrder - b.category.sortOrder),
)
const completedCount = computed(() => resultRows.value.filter((row) => row.isComplete).length)
const missingCount = computed(() => Math.max(0, resultRows.value.length - completedCount.value))
const reviewWarningCount = computed(() => resultRows.value.filter((row) => row.hasPendingReviews).length)
const emptyCount = computed(() => resultRows.value.filter((row) => row.isEmpty).length)
const completionPct = computed(() => resultRows.value.length === 0 ? 0 : Math.round((completedCount.value / resultRows.value.length) * 100))
const statusFilters = computed(() => [
{ value: allFilter, label: 'Alle', count: resultRows.value.length },
{ value: 'open', label: 'Offen', count: missingCount.value },
{ value: 'set', label: 'Gesetzt', count: completedCount.value },
{ value: 'review', label: 'Reviews offen', count: reviewWarningCount.value },
{ value: 'empty', label: 'Ohne Kandidaten', count: emptyCount.value },
])
const visibleResultRows = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase()
return resultRows.value.filter((row) => {
const matchesStatus = statusFilter.value === allFilter
|| (statusFilter.value === 'open' && !row.isComplete)
|| (statusFilter.value === 'set' && row.isComplete)
|| (statusFilter.value === 'review' && row.hasPendingReviews)
|| (statusFilter.value === 'empty' && row.isEmpty)
if (!matchesStatus) return false
if (!normalizedQuery) return true
return [
row.category.name,
row.category.groupName,
row.existing?.candidateDisplayName ?? '',
...row.candidates.map((candidate) => `${candidate.displayName} ${candidate.channelSlug} ${candidate.platform}`),
].some((value) => value.toLowerCase().includes(normalizedQuery))
})
})
const summaryCards = computed(() => [
{
label: 'Fortschritt',
value: `${completionPct.value}%`,
note: `${completedCount.value} von ${resultRows.value.length} Kategorien final.`,
icon: Trophy,
tone: 'border-violet-100 bg-violet-50 text-violet-800',
},
{
label: 'Offen',
value: String(missingCount.value),
note: missingCount.value === 0 ? 'Keine Gewinner fehlen.' : 'Diese Kategorien brauchen eine Auswahl.',
icon: ListChecks,
tone: missingCount.value > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
{
label: 'Reviews',
value: String(reviewWarningCount.value),
note: reviewWarningCount.value === 0 ? 'Keine Review-Warnungen.' : 'Vor finaler Freigabe pruefen.',
icon: Clock3,
tone: reviewWarningCount.value > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
{
label: 'Gesetzt',
value: String(completedCount.value),
note: 'Speist Archiv und Gewinneransicht.',
icon: CheckCircle2,
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
])
watch(resultRows, (rows) => {
const knownCategoryIds = new Set(rows.map((row) => row.category.id))
for (const key of Object.keys(winnerSelections)) {
if (!knownCategoryIds.has(Number(key))) {
delete winnerSelections[Number(key)]
}
}
for (const row of rows) {
winnerSelections[row.category.id] = row.existing?.candidateId ? String(row.existing.candidateId) : ''
}
}, { immediate: true })
async function saveWinner(categoryId: number) {
const candidateId = Number(winnerSelections[categoryId])
if (!candidateId || !seasonDetail.value.id) return
savingResultForCategory.value = categoryId
adminMessage.value = ''
adminError.value = ''
try {
await store.setAdminResult(seasonDetail.value.id, { categoryId, candidateId })
adminMessage.value = 'Gewinner wurde gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Gewinner konnte nicht gespeichert werden.'
} finally {
savingResultForCategory.value = null
}
}
async function clearWinner(resultId: number) {
if (!seasonDetail.value.id) return
deletingResultId.value = resultId
adminMessage.value = ''
adminError.value = ''
try {
await store.deleteAdminResult(resultId, seasonDetail.value.id)
adminMessage.value = 'Gewinner wurde entfernt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Gewinner konnte nicht entfernt werden.'
} finally {
deletingResultId.value = null
}
}
return {
adminError,
adminMessage,
completionPct,
deletingResultId,
query,
savingResultForCategory,
statusFilter,
statusFilters,
summaryCards,
visibleResultRows,
winnerSelections,
clearWinner,
saveWinner,
}
}