18b61bed52
- Widen AdminCandidateEditorModal to size lg for better readability - Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link" - Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport - Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
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<number | null>(null)
|
|
const readinessFilter = ref('all')
|
|
const page = ref(1)
|
|
const modalOpen = ref(false)
|
|
const editingId = ref<number | 'new' | null>(null)
|
|
const candidateToDelete = ref<AdminCandidateItem | null>(null)
|
|
const form = reactive<CandidateForm>({
|
|
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<Record<number, string>>(() =>
|
|
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])),
|
|
)
|
|
const duplicateCandidateKeys = computed(() => {
|
|
const counts = new Map<string, number>()
|
|
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<number, Array<{ mode: 'warn' | 'block'; message: string }>> = {}
|
|
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<number, number>()
|
|
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<string, number>()
|
|
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<number, Array<{ mode: 'warn' | 'block'; message: string }>>,
|
|
candidateId: number,
|
|
rule: AdminWorkflowRule,
|
|
message: string,
|
|
) {
|
|
const mode = rule.mode === 'warn' ? 'warn' : 'block'
|
|
notices[candidateId] = [...(notices[candidateId] ?? []), { mode, message }]
|
|
}
|
|
|
|
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'>) {
|
|
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<string, number>) {
|
|
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
|
|
}
|
|
}
|