Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { computed } from 'vue'
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Sparkles, Tags, Trophy, Users, Vote } from '@lucide/vue'
|
||||
|
||||
import { getVoteMetricValue } from '../../lib/adminMetrics'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
export function useAdminAnalyticsManager() {
|
||||
const store = useAwardsStore()
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const topCategories = computed(() => store.admin.topCategories)
|
||||
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
|
||||
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
|
||||
const maxVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
|
||||
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
|
||||
const voteMap = computed(() => new Map(topCategories.value.map((category) => [category.category, category.votes])))
|
||||
|
||||
const categoryHealth = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => {
|
||||
const candidates = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length
|
||||
const reviews = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
|
||||
const hasWinner = resultMap.value.has(category.id)
|
||||
const votes = voteMap.value.get(category.name) ?? 0
|
||||
const status = candidates === 0 ? 'Leer' : reviews > 0 ? 'Review offen' : hasWinner ? 'Gewinner gesetzt' : 'Bereit'
|
||||
|
||||
return {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
groupName: category.groupName,
|
||||
candidates,
|
||||
reviews,
|
||||
hasWinner,
|
||||
votes,
|
||||
status,
|
||||
statusClass: candidates === 0
|
||||
? 'border-rose-100 bg-rose-50 text-rose-700'
|
||||
: reviews > 0
|
||||
? 'border-amber-100 bg-amber-50 text-amber-700'
|
||||
: hasWinner
|
||||
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
: 'border-sky-100 bg-sky-50 text-sky-700',
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.reviews - a.reviews || a.candidates - b.candidates || b.votes - a.votes),
|
||||
)
|
||||
|
||||
const emptyCategories = computed(() => categoryHealth.value.filter((category) => category.candidates === 0))
|
||||
const categoriesWithReviews = computed(() => categoryHealth.value.filter((category) => category.reviews > 0))
|
||||
const categoriesWithoutWinner = computed(() => categoryHealth.value.filter((category) => !category.hasWinner))
|
||||
const pendingClipCount = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
|
||||
const winnerCoveragePct = computed(() => {
|
||||
const categoryCount = seasonDetail.value.categories.length
|
||||
if (categoryCount === 0) return 0
|
||||
return Math.round((seasonDetail.value.results.length / categoryCount) * 100)
|
||||
})
|
||||
|
||||
const metricCards = computed(() => [
|
||||
{ label: 'Nominierungen', value: totalNominations.value, note: 'eingereicht im Jahr', icon: Sparkles, tone: 'text-fuchsia-700 bg-fuchsia-50 border-fuchsia-100' },
|
||||
{ label: 'Stimmen', value: totalVotes.value, note: 'gezählte Votes', icon: Vote, tone: 'text-violet-700 bg-violet-50 border-violet-100' },
|
||||
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'freigegeben in Kategorien', icon: Users, tone: 'text-cyan-700 bg-cyan-50 border-cyan-100' },
|
||||
{ label: 'Review-Backlog', value: seasonDetail.value.pendingNominations.length, note: 'offene Entscheidungen', icon: Clock3, tone: 'text-amber-700 bg-amber-50 border-amber-100' },
|
||||
])
|
||||
|
||||
const readinessCards = computed(() => [
|
||||
{
|
||||
label: 'Kategorie-Abdeckung',
|
||||
value: `${seasonDetail.value.categories.length - emptyCategories.value.length}/${seasonDetail.value.categories.length}`,
|
||||
note: emptyCategories.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${emptyCategories.value.length} Kategorien sind noch leer.`,
|
||||
icon: Tags,
|
||||
to: emptyCategories.value.length > 0 ? '/admin/candidates' : '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Gewinnerstatus',
|
||||
value: `${winnerCoveragePct.value}%`,
|
||||
note: `${seasonDetail.value.results.length} von ${seasonDetail.value.categories.length} Kategorien final gesetzt.`,
|
||||
icon: Trophy,
|
||||
to: '/admin/winners',
|
||||
},
|
||||
{
|
||||
label: 'Freigabe-Risiko',
|
||||
value: String(categoriesWithReviews.value.length + pendingClipCount.value),
|
||||
note: `${categoriesWithReviews.value.length} Kategorien mit Reviews, ${pendingClipCount.value} Clips offen.`,
|
||||
icon: AlertTriangle,
|
||||
to: categoriesWithReviews.value.length > 0 ? '/admin/nominations?review=1' : '/admin/clips',
|
||||
},
|
||||
])
|
||||
|
||||
const insightCards = computed(() => {
|
||||
const votesPerCandidate = seasonDetail.value.candidates.length === 0 ? 0 : Math.round(totalVotes.value / seasonDetail.value.candidates.length)
|
||||
const busiestReviewCategory = categoriesWithReviews.value[0]
|
||||
const strongestCategory = topCategories.value[0]
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Votes pro Kandidat',
|
||||
value: votesPerCandidate.toLocaleString('de-DE'),
|
||||
note: 'Zeigt, ob die Kandidatenbasis breit genug fuer die aktuelle Vote-Menge ist.',
|
||||
icon: Vote,
|
||||
},
|
||||
{
|
||||
label: 'Staerkste Kategorie',
|
||||
value: strongestCategory?.votes.toLocaleString('de-DE') ?? '0',
|
||||
note: strongestCategory ? strongestCategory.category : 'Noch keine Vote-Verteilung vorhanden.',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
label: 'Review-Hotspot',
|
||||
value: String(busiestReviewCategory?.reviews ?? 0),
|
||||
note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine offenen Review-Hotspots.',
|
||||
icon: Clock3,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const attentionItems = computed(() => [
|
||||
{
|
||||
key: 'empty-categories',
|
||||
label: 'Leere Kategorien',
|
||||
value: emptyCategories.value.length,
|
||||
note: emptyCategories.value.length === 0 ? 'Keine Luecke in der Kandidatenbasis.' : emptyCategories.value.slice(0, 3).map((category) => category.name).join(', '),
|
||||
to: '/admin/candidates',
|
||||
tone: emptyCategories.value.length > 0 ? 'border-rose-100 bg-rose-50 text-rose-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
key: 'pending-reviews',
|
||||
label: 'Offene Reviews',
|
||||
value: seasonDetail.value.pendingNominations.length,
|
||||
note: categoriesWithReviews.value.length === 0 ? 'Review-Queue ist leer.' : `${categoriesWithReviews.value.length} Kategorien betroffen.`,
|
||||
to: '/admin/nominations?review=1',
|
||||
tone: seasonDetail.value.pendingNominations.length > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
key: 'missing-winners',
|
||||
label: 'Gewinner fehlen',
|
||||
value: categoriesWithoutWinner.value.length,
|
||||
note: categoriesWithoutWinner.value.length === 0 ? 'Alle Gewinner sind gesetzt.' : 'Finalisierung auf der Gewinner-Seite abschliessen.',
|
||||
to: '/admin/winners',
|
||||
tone: categoriesWithoutWinner.value.length > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
categoryHealth,
|
||||
metricCards,
|
||||
readinessCards,
|
||||
insightCards,
|
||||
attentionItems,
|
||||
topCategories,
|
||||
maxVotes,
|
||||
winnerCoveragePct,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user