497 lines
19 KiB
Vue
497 lines
19 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Page Header -->
|
|
<div class="mb-8 flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-white">{{ t('gamification.competitions.title') }}</h1>
|
|
<p class="text-slate-400 mt-1">{{ t('gamification.competitions.subtitle') }}</p>
|
|
</div>
|
|
<button
|
|
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition"
|
|
@click="openCreateModal"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
{{ t('gamification.competitions.create') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Season Filter -->
|
|
<div class="mb-6">
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<label class="text-sm text-slate-400">{{ t('gamification.competitions.season_filter') }}</label>
|
|
<select
|
|
v-model="selectedSeasonId"
|
|
class="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
@change="fetchCompetitions"
|
|
>
|
|
<option :value="null">{{ t('gamification.competitions.all_seasons') }}</option>
|
|
<option v-for="s in seasons" :key="s.id" :value="s.id">
|
|
{{ s.name }} {{ s.is_active ? t('gamification.competitions.active_badge') : '' }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="flex items-center justify-center py-20">
|
|
<div class="text-slate-400 flex items-center gap-3">
|
|
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
<span>{{ t('gamification.competitions.loading') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
|
|
<p class="text-rose-400">{{ t('gamification.competitions.error') }}</p>
|
|
<button class="mt-2 text-sm text-rose-300 hover:text-rose-200 underline" @click="fetchCompetitions">{{ t('gamification.competitions.retry') }}</button>
|
|
</div>
|
|
|
|
<!-- Competitions List -->
|
|
<template v-else>
|
|
<!-- Empty State -->
|
|
<div v-if="competitions.length === 0" class="bg-slate-800 rounded-xl border border-slate-700 p-12 text-center">
|
|
<svg class="w-12 h-12 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" />
|
|
</svg>
|
|
<p class="text-slate-400">{{ t('gamification.competitions.no_items') }}</p>
|
|
<button
|
|
class="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition"
|
|
@click="openCreateModal"
|
|
>
|
|
{{ t('gamification.competitions.create_first') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Competition Cards -->
|
|
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<div
|
|
v-for="comp in competitions"
|
|
:key="comp.id"
|
|
class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-slate-600 transition"
|
|
>
|
|
<div class="flex items-start justify-between mb-3">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-white">{{ comp.name }}</h3>
|
|
<p v-if="comp.description" class="text-sm text-slate-400 mt-1 line-clamp-2">{{ comp.description }}</p>
|
|
</div>
|
|
<span
|
|
class="px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ml-2"
|
|
:class="getStatusClass(comp.status)"
|
|
>
|
|
{{ getStatusLabel(comp.status) }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="space-y-2 text-sm text-slate-400 mb-4">
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
<span>{{ formatDate(comp.start_date) }} → {{ formatDate(comp.end_date) }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
|
</svg>
|
|
<span>{{ t('gamification.competitions.season_id_label') }} {{ comp.season_id }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Rules Preview -->
|
|
<div v-if="comp.rules && Object.keys(comp.rules).length > 0" class="mb-4">
|
|
<details class="text-sm">
|
|
<summary class="text-indigo-400 cursor-pointer hover:text-indigo-300">{{ t('gamification.competitions.view_rules') }}</summary>
|
|
<pre class="mt-2 p-3 bg-slate-900 rounded-lg text-xs text-slate-300 overflow-x-auto">{{ JSON.stringify(comp.rules, null, 2) }}</pre>
|
|
</details>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex items-center gap-2 pt-2 border-t border-slate-700/50">
|
|
<button
|
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-indigo-400 hover:bg-indigo-500/10 transition"
|
|
@click="openEditModal(comp)"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
{{ t('gamification.competitions.edit') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Create/Edit Modal -->
|
|
<div
|
|
v-if="showModal"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
>
|
|
<div class="fixed inset-0 bg-black/60" @click="closeModal" />
|
|
<div class="relative bg-slate-800 rounded-xl border border-slate-700 w-full max-w-lg p-6 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
<h2 class="text-lg font-semibold text-white mb-6">
|
|
{{ editingCompetition ? t('gamification.competitions.edit_title') : t('gamification.competitions.create_title') }}
|
|
</h2>
|
|
|
|
<form @submit.prevent="saveCompetition" class="space-y-4">
|
|
<!-- Name -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.name') }}</label>
|
|
<input
|
|
v-model="form.name"
|
|
type="text"
|
|
required
|
|
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
:placeholder="t('gamification.competitions.name_placeholder')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.description') }}</label>
|
|
<textarea
|
|
v-model="form.description"
|
|
rows="3"
|
|
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
:placeholder="t('gamification.competitions.desc_placeholder')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Season -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.season') }}</label>
|
|
<select
|
|
v-model="form.season_id"
|
|
required
|
|
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
>
|
|
<option value="" disabled>{{ t('gamification.competitions.select_season') }}</option>
|
|
<option v-for="s in seasons" :key="s.id" :value="s.id">
|
|
{{ s.name }} {{ s.is_active ? t('gamification.competitions.active_badge') : '' }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Start Date -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.start_date') }}</label>
|
|
<input
|
|
v-model="form.start_date"
|
|
type="date"
|
|
required
|
|
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<!-- End Date -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.end_date') }}</label>
|
|
<input
|
|
v-model="form.end_date"
|
|
type="date"
|
|
required
|
|
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.status') }}</label>
|
|
<select
|
|
v-model="form.status"
|
|
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
>
|
|
<option value="draft">{{ t('gamification.competitions.status_draft') }}</option>
|
|
<option value="active">{{ t('gamification.competitions.status_active') }}</option>
|
|
<option value="completed">{{ t('gamification.competitions.status_completed') }}</option>
|
|
<option value="cancelled">{{ t('gamification.competitions.status_cancelled') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Rules (JSON) -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.rules') }}</label>
|
|
<textarea
|
|
v-model="form.rulesJson"
|
|
rows="5"
|
|
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
:placeholder="t('gamification.competitions.rules_placeholder')"
|
|
/>
|
|
<p v-if="rulesParseError" class="text-xs text-rose-400 mt-1">{{ t('gamification.competitions.invalid_json') }}</p>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div v-if="formError" class="bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
|
|
<p class="text-sm text-rose-400">{{ formError }}</p>
|
|
</div>
|
|
|
|
<!-- Buttons -->
|
|
<div class="flex items-center justify-end gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
|
|
@click="closeModal"
|
|
>
|
|
{{ t('gamification.competitions.cancel') }}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
:disabled="saving || rulesParseError"
|
|
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white text-sm font-medium rounded-lg transition flex items-center gap-2"
|
|
>
|
|
<svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
{{ editingCompetition ? t('gamification.competitions.save') : t('gamification.competitions.create_btn') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
middleware: 'auth',
|
|
})
|
|
|
|
const { t } = useI18n()
|
|
|
|
interface Season {
|
|
id: number
|
|
name: string
|
|
start_date: string
|
|
end_date: string
|
|
is_active: boolean
|
|
created_at: string | null
|
|
}
|
|
|
|
interface Competition {
|
|
id: number
|
|
name: string
|
|
description: string | null
|
|
season_id: number
|
|
start_date: string
|
|
end_date: string
|
|
rules: Record<string, any> | null
|
|
status: string
|
|
created_at: string | null
|
|
updated_at: string | null
|
|
}
|
|
|
|
interface CompetitionForm {
|
|
name: string
|
|
description: string
|
|
season_id: number | ''
|
|
start_date: string
|
|
end_date: string
|
|
status: string
|
|
rulesJson: string
|
|
}
|
|
|
|
const loading = ref(true)
|
|
const error = ref(false)
|
|
const saving = ref(false)
|
|
const showModal = ref(false)
|
|
const seasons = ref<Season[]>([])
|
|
const competitions = ref<Competition[]>([])
|
|
const editingCompetition = ref<Competition | null>(null)
|
|
const selectedSeasonId = ref<number | null>(null)
|
|
const formError = ref('')
|
|
const rulesParseError = ref(false)
|
|
|
|
const form = reactive<CompetitionForm>({
|
|
name: '',
|
|
description: '',
|
|
season_id: '',
|
|
start_date: '',
|
|
end_date: '',
|
|
status: 'draft',
|
|
rulesJson: '',
|
|
})
|
|
|
|
function getAuthHeaders() {
|
|
const tokenCookie = useCookie('access_token')
|
|
const token = tokenCookie.value
|
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
if (!dateStr) return ''
|
|
const d = new Date(dateStr)
|
|
return d.toLocaleDateString('hu-HU', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
}
|
|
|
|
function getStatusClass(status: string): string {
|
|
switch (status) {
|
|
case 'active': return 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30'
|
|
case 'draft': return 'bg-slate-700 text-slate-400 border border-slate-600'
|
|
case 'completed': return 'bg-blue-500/10 text-blue-400 border border-blue-500/30'
|
|
case 'cancelled': return 'bg-rose-500/10 text-rose-400 border border-rose-500/30'
|
|
default: return 'bg-slate-700 text-slate-400 border border-slate-600'
|
|
}
|
|
}
|
|
|
|
function getStatusLabel(status: string): string {
|
|
switch (status) {
|
|
case 'active': return t('gamification.competitions.status_active')
|
|
case 'draft': return t('gamification.competitions.status_draft')
|
|
case 'completed': return t('gamification.competitions.status_completed')
|
|
case 'cancelled': return t('gamification.competitions.status_cancelled')
|
|
default: return status
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
form.name = ''
|
|
form.description = ''
|
|
form.season_id = ''
|
|
form.start_date = ''
|
|
form.end_date = ''
|
|
form.status = 'draft'
|
|
form.rulesJson = ''
|
|
formError.value = ''
|
|
rulesParseError.value = false
|
|
}
|
|
|
|
function openCreateModal() {
|
|
editingCompetition.value = null
|
|
resetForm()
|
|
showModal.value = true
|
|
}
|
|
|
|
function openEditModal(comp: Competition) {
|
|
editingCompetition.value = comp
|
|
form.name = comp.name
|
|
form.description = comp.description || ''
|
|
form.season_id = comp.season_id
|
|
form.start_date = comp.start_date
|
|
form.end_date = comp.end_date
|
|
form.status = comp.status
|
|
form.rulesJson = comp.rules ? JSON.stringify(comp.rules, null, 2) : ''
|
|
formError.value = ''
|
|
rulesParseError.value = false
|
|
showModal.value = true
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal.value = false
|
|
editingCompetition.value = null
|
|
resetForm()
|
|
}
|
|
|
|
function parseRules(): Record<string, any> | null {
|
|
if (!form.rulesJson.trim()) return null
|
|
try {
|
|
const parsed = JSON.parse(form.rulesJson)
|
|
rulesParseError.value = false
|
|
return parsed
|
|
} catch {
|
|
rulesParseError.value = true
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function fetchSeasons() {
|
|
try {
|
|
const data = await $fetch('/api/v1/admin/gamification/seasons', {
|
|
headers: getAuthHeaders(),
|
|
})
|
|
seasons.value = data as Season[]
|
|
} catch (e) {
|
|
console.error('Failed to fetch seasons:', e)
|
|
}
|
|
}
|
|
|
|
async function fetchCompetitions() {
|
|
loading.value = true
|
|
error.value = false
|
|
try {
|
|
const params: Record<string, any> = {}
|
|
if (selectedSeasonId.value) {
|
|
params.season_id = selectedSeasonId.value
|
|
}
|
|
const query = new URLSearchParams(params).toString()
|
|
const url = `/api/v1/admin/gamification/competitions${query ? `?${query}` : ''}`
|
|
const data = await $fetch(url, {
|
|
headers: getAuthHeaders(),
|
|
})
|
|
competitions.value = data as Competition[]
|
|
} catch (e) {
|
|
console.error('Failed to fetch competitions:', e)
|
|
error.value = true
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function saveCompetition() {
|
|
saving.value = true
|
|
formError.value = ''
|
|
|
|
// Validate rules JSON if provided
|
|
let rules = null
|
|
if (form.rulesJson.trim()) {
|
|
rules = parseRules()
|
|
if (rulesParseError.value) {
|
|
saving.value = false
|
|
return
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (editingCompetition.value) {
|
|
// Update existing competition
|
|
const payload: Record<string, any> = {}
|
|
if (form.name !== editingCompetition.value.name) payload.name = form.name
|
|
if (form.description !== (editingCompetition.value.description || '')) payload.description = form.description || null
|
|
if (form.season_id !== editingCompetition.value.season_id) payload.season_id = form.season_id
|
|
if (form.start_date !== editingCompetition.value.start_date) payload.start_date = form.start_date
|
|
if (form.end_date !== editingCompetition.value.end_date) payload.end_date = form.end_date
|
|
if (form.status !== editingCompetition.value.status) payload.status = form.status
|
|
if (rules !== null) payload.rules = rules
|
|
|
|
if (Object.keys(payload).length > 0) {
|
|
await $fetch(`/api/v1/admin/gamification/competitions/${editingCompetition.value.id}`, {
|
|
method: 'PUT',
|
|
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
|
body: payload,
|
|
})
|
|
}
|
|
} else {
|
|
// Create new competition
|
|
await $fetch('/api/v1/admin/gamification/competitions', {
|
|
method: 'POST',
|
|
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
|
body: {
|
|
name: form.name,
|
|
description: form.description || null,
|
|
season_id: form.season_id,
|
|
start_date: form.start_date,
|
|
end_date: form.end_date,
|
|
status: form.status,
|
|
rules: rules,
|
|
},
|
|
})
|
|
}
|
|
|
|
closeModal()
|
|
await fetchCompetitions()
|
|
} catch (e: any) {
|
|
console.error('Failed to save competition:', e)
|
|
formError.value = e?.data?.detail || e?.message || t('gamification.competitions.save_error')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await fetchSeasons()
|
|
await fetchCompetitions()
|
|
})
|
|
</script>
|