import { computed, reactive, ref, watch } from 'vue' import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons' import { useAwardsStore } from '../../stores/awards' import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards' interface CandidateForm { categoryId: number displayName: string channelSlug: string platform: string acceptanceStatus: string acceptanceNote: string clipCompilationUrl: string clipCompilationTitle: string clipCompilationPlatform: string clipEmbedStatus: string } const pageSize = 10 const maxFinalistsRuleKey = 'max_finalists_per_category' const maxCandidateAppearancesRuleKey = 'max_candidate_appearances' const acceptanceStatusOptions = [ { label: 'Offen', value: 'open', description: 'Noch nicht kontaktiert' }, { label: 'Angefragt', value: 'contacted', description: 'Kontakt läuft' }, { label: 'Angenommen', value: 'accepted', description: 'nimmt teil' }, { label: 'Abgesagt', value: 'declined', description: 'nicht voting-bereit' }, ] const clipEmbedStatusOptions = [ { label: 'Noch nicht geprüft', value: 'unchecked' }, { label: 'Einbettbar', value: 'embeddable' }, { label: 'Nur Link', value: 'link_only' }, { label: 'Nicht nutzbar', value: 'blocked' }, ] const readinessFilterOptions = [ { label: 'Alle', value: 'all' }, { label: 'Offen', value: 'open' }, { label: 'Angefragt', value: 'contacted' }, { label: 'Angenommen', value: 'accepted' }, { label: 'Abgesagt', value: 'declined' }, { label: 'Clip fehlt', value: 'missing_clip' }, { label: 'Embed prüfen', value: 'embed_review' }, ] export function useAdminCandidateManager() { const store = useAwardsStore() const saving = ref(false) const deleting = ref(false) const adminMessage = ref('') const adminError = ref('') const search = ref('') const categoryFilter = ref(null) const readinessFilter = ref('all') const page = ref(1) const modalOpen = ref(false) const editingId = ref(null) const candidateToDelete = ref(null) const form = reactive({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch', acceptanceStatus: 'open', acceptanceNote: '', clipCompilationUrl: '', clipCompilationTitle: '', clipCompilationPlatform: '', clipEmbedStatus: 'unchecked', }) const seasonDetail = computed(() => store.adminSeasonDetail) const workflowRules = computed(() => store.adminWorkflowRules.rules) const selectedSeasonId = computed(() => store.adminSelectedSeasonId) const categoryOptions = computed(() => seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })), ) const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value]) const categoryLabelMap = computed>(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])), ) const duplicateCandidateKeys = computed(() => { const counts = new Map() for (const candidate of seasonDetail.value.candidates) { const nameKey = createCandidateDuplicateKey(candidate, 'name') const slugKey = createCandidateDuplicateKey(candidate, 'slug') counts.set(nameKey, (counts.get(nameKey) ?? 0) + 1) if (candidate.channelSlug.trim()) { counts.set(slugKey, (counts.get(slugKey) ?? 0) + 1) } } return counts }) const duplicateCandidateCount = computed(() => seasonDetail.value.candidates.filter((candidate) => hasDuplicateCandidateKey(candidate, duplicateCandidateKeys.value)).length, ) const candidateWorkflowNotices = computed(() => buildCandidateWorkflowNotices( seasonDetail.value.candidates, workflowRules.value, )) const acceptedCandidateCount = computed(() => seasonDetail.value.candidates.filter((candidate) => candidate.acceptanceStatus === 'accepted').length, ) const clipReadyCount = computed(() => seasonDetail.value.candidates.filter((candidate) => hasUsableCompilation(candidate)).length, ) const actionNeededCount = computed(() => seasonDetail.value.candidates.filter((candidate) => candidate.acceptanceStatus !== 'accepted' || !hasUsableCompilation(candidate) || candidate.clipEmbedStatus === 'unchecked', ).length, ) const filteredCandidates = computed(() => { const query = search.value.trim().toLowerCase() let list = seasonDetail.value.candidates if (categoryFilter.value) { list = list.filter((candidate) => candidate.categoryId === categoryFilter.value) } if (readinessFilter.value !== 'all') { list = list.filter((candidate) => { if (readinessFilter.value === 'missing_clip') { return !hasUsableCompilation(candidate) } if (readinessFilter.value === 'embed_review') { return Boolean(candidate.clipCompilationUrl?.trim()) && candidate.clipEmbedStatus === 'unchecked' } return candidate.acceptanceStatus === readinessFilter.value }) } if (query) { list = list.filter((candidate) => [candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? ''] .join(' ') .toLowerCase() .includes(query), ) } return list }) const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize))) const pagedCandidates = computed(() => { const start = (page.value - 1) * pageSize return filteredCandidates.value.slice(start, start + pageSize) }) const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1)) const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length)) const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten')) const canSave = computed(() => Boolean( selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim() && form.platform.trim() && isValidOptionalUrl(form.clipCompilationUrl), ), ) const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website')) const selectedPlatformValue = computed(() => socialIconOptionForValue(form.platform)?.key ?? 'custom') watch([search, categoryFilter, readinessFilter, () => seasonDetail.value.candidates.length], () => { page.value = 1 }) watch(totalPages, (max) => { if (page.value > max) { page.value = max } }) function clearFilters() { search.value = '' categoryFilter.value = null readinessFilter.value = 'all' } function resetPreparationForm() { form.acceptanceStatus = 'open' form.acceptanceNote = '' form.clipCompilationUrl = '' form.clipCompilationTitle = '' form.clipCompilationPlatform = '' form.clipEmbedStatus = 'unchecked' } function openCreate() { adminMessage.value = '' adminError.value = '' editingId.value = 'new' form.categoryId = categoryFilter.value ?? seasonDetail.value.categories[0]?.id ?? 0 form.displayName = '' form.channelSlug = '' form.platform = 'Twitch' resetPreparationForm() modalOpen.value = true } function openEdit(candidate: AdminCandidateItem) { adminMessage.value = '' adminError.value = '' editingId.value = candidate.id form.categoryId = candidate.categoryId form.displayName = candidate.displayName form.channelSlug = candidate.channelSlug form.platform = candidate.platform form.acceptanceStatus = candidate.acceptanceStatus || 'open' form.acceptanceNote = candidate.acceptanceNote ?? '' form.clipCompilationUrl = candidate.clipCompilationUrl ?? '' form.clipCompilationTitle = candidate.clipCompilationTitle ?? '' form.clipCompilationPlatform = candidate.clipCompilationPlatform ?? '' form.clipEmbedStatus = candidate.clipEmbedStatus || 'unchecked' modalOpen.value = true } function handlePlatformSelection(value: string) { if (value === 'custom') { if (socialIconOptionForValue(form.platform)) { form.platform = '' } return } const option = socialIconOptionForValue(value) form.platform = option?.label ?? value } async function saveModal() { if (!canSave.value || !selectedSeasonId.value) { return } saving.value = true adminError.value = '' try { if (editingId.value === 'new') { await store.createAdminCandidate(selectedSeasonId.value, buildCandidatePayload()) adminMessage.value = `„${form.displayName}" wurde angelegt.` } else if (typeof editingId.value === 'number') { await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, buildCandidatePayload()) adminMessage.value = `„${form.displayName}" wurde gespeichert.` } modalOpen.value = false } catch (error) { adminError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.' } finally { saving.value = false } } function buildCandidatePayload() { const clipUrl = form.clipCompilationUrl.trim() return { categoryId: form.categoryId, displayName: form.displayName, channelSlug: form.channelSlug, platform: form.platform, acceptanceStatus: form.acceptanceStatus, acceptanceNote: form.acceptanceNote.trim() || null, clipCompilationUrl: clipUrl || null, clipCompilationTitle: clipUrl ? form.clipCompilationTitle.trim() || null : null, clipCompilationPlatform: clipUrl ? form.clipCompilationPlatform.trim() || null : null, clipEmbedStatus: clipUrl ? form.clipEmbedStatus : 'unchecked', } } async function confirmDelete() { if (!candidateToDelete.value || !selectedSeasonId.value) { return } deleting.value = true adminError.value = '' try { await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value) adminMessage.value = `„${candidateToDelete.value.displayName}" wurde gelöscht.` candidateToDelete.value = null } catch (error) { adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.' } finally { deleting.value = false } } return { seasonDetail, saving, deleting, adminMessage, adminError, search, categoryFilter, readinessFilter, page, categoryOptions, categoryFilterOptions, categoryLabelMap, duplicateCandidateKeys, candidateWorkflowNotices, duplicateCandidateCount, acceptedCandidateCount, clipReadyCount, actionNeededCount, filteredCandidates, pagedCandidates, totalPages, rangeStart, rangeEnd, modalOpen, form, modalTitle, canSave, candidatePlatformOptions, selectedPlatformValue, acceptanceStatusOptions, clipEmbedStatusOptions, readinessFilterOptions, candidateToDelete, clearFilters, openCreate, openEdit, handlePlatformSelection, saveModal, confirmDelete, } } function buildCandidateWorkflowNotices(candidates: AdminCandidateItem[], rules: AdminWorkflowRule[]) { const notices: Record> = {} const activeCandidates = candidates.filter((candidate) => candidate.acceptanceStatus !== 'declined') const finalistsRule = rules.find((rule) => rule.key === maxFinalistsRuleKey) const appearancesRule = rules.find((rule) => rule.key === maxCandidateAppearancesRuleKey) if (finalistsRule?.enabled) { const categoryCounts = new Map() for (const candidate of activeCandidates) { categoryCounts.set(candidate.categoryId, (categoryCounts.get(candidate.categoryId) ?? 0) + 1) } for (const candidate of activeCandidates) { const count = categoryCounts.get(candidate.categoryId) ?? 0 if (count > finalistsRule.limit) { addCandidateNotice(notices, candidate.id, finalistsRule, `${count}/${finalistsRule.limit} finale Kandidat:innen in dieser Kategorie.`) } } } if (appearancesRule?.enabled) { const identityCounts = new Map() for (const candidate of activeCandidates) { const key = candidateIdentityKey(candidate) identityCounts.set(key, (identityCounts.get(key) ?? 0) + 1) } for (const candidate of activeCandidates) { const count = identityCounts.get(candidateIdentityKey(candidate)) ?? 0 if (count > appearancesRule.limit) { addCandidateNotice(notices, candidate.id, appearancesRule, `${count}/${appearancesRule.limit} Kandidaturen für diese Person.`) } } } return notices } function addCandidateNotice( notices: Record>, candidateId: number, rule: AdminWorkflowRule, message: string, ) { const mode = rule.mode === 'warn' ? 'warn' : 'block' notices[candidateId] = [...(notices[candidateId] ?? []), { mode, message }] } function candidateIdentityKey(candidate: Pick) { const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase() return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}` } function createCandidateDuplicateKey(candidate: AdminCandidateItem, field: 'name' | 'slug') { const value = field === 'name' ? candidate.displayName : candidate.channelSlug return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}` } function hasDuplicateCandidateKey(candidate: AdminCandidateItem, candidateKeys: Map) { return (candidateKeys.get(createCandidateDuplicateKey(candidate, 'name')) ?? 0) > 1 || (candidateKeys.get(createCandidateDuplicateKey(candidate, 'slug')) ?? 0) > 1 } function hasUsableCompilation(candidate: AdminCandidateItem) { return Boolean(candidate.clipCompilationUrl?.trim()) && candidate.clipEmbedStatus !== 'blocked' } function isValidOptionalUrl(value: string) { const trimmed = value.trim() if (!trimmed) return true try { const url = new URL(trimmed) return url.protocol === 'http:' || url.protocol === 'https:' } catch { return false } }