frontend admin refakctorálás
This commit is contained in:
274
frontend_admin/pages/gamification/validation-rules.vue
Normal file
274
frontend_admin/pages/gamification/validation-rules.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">{{ t('gamification.validation_rules.title') }}</h1>
|
||||
<p class="text-slate-400 mt-1">{{ t('gamification.validation_rules.subtitle') }}</p>
|
||||
</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.validation_rules.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.validation_rules.load_error') }}</p>
|
||||
<button @click="fetchRules" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.validation_rules.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Save Button Bar -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-slate-500">{{ t('gamification.validation_rules.last_saved') }} {{ lastSaved ? lastSaved : '—' }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="saveRules"
|
||||
:disabled="saving || !hasChanges"
|
||||
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-600/50 text-white rounded-lg text-sm font-medium 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>
|
||||
{{ saving ? t('gamification.validation_rules.saving') : t('gamification.validation_rules.save_rules') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Rules Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<div
|
||||
v-for="rule in rules"
|
||||
:key="rule.rule_key"
|
||||
class="bg-slate-800 rounded-xl border border-slate-700 p-6"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white font-mono">{{ rule.rule_key }}</h2>
|
||||
<p class="text-sm text-slate-400 mt-1">{{ rule.description }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-1 rounded-full"
|
||||
:class="getValueBadgeClass(rule.rule_value)"
|
||||
>
|
||||
{{ rule.rule_value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Number Input -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
:value="editedValues[rule.rule_key] ?? rule.rule_value"
|
||||
@input="onValueChange(rule.rule_key, ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Visual progress bar -->
|
||||
<div class="w-full bg-slate-700 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="getProgressBarClass(rule.rule_value)"
|
||||
:style="{ width: getProgressWidth(rule.rule_key) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Change indicator -->
|
||||
<div v-if="hasRuleChanged(rule.rule_key)" class="flex items-center gap-1 text-xs">
|
||||
<span class="text-amber-400">{{ t('gamification.validation_rules.changed_from') }} {{ getOriginalValue(rule.rule_key) }}</span>
|
||||
<svg class="w-3 h-3 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span class="text-emerald-400">{{ editedValues[rule.rule_key] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="bg-slate-800/50 border border-slate-700/50 rounded-xl p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-indigo-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-white mb-1">{{ t('gamification.validation_rules.info_title') }}</h3>
|
||||
<p class="text-sm text-slate-400">{{ t('gamification.validation_rules.info_text') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Form Error Toast -->
|
||||
<div v-if="formError" class="fixed bottom-6 right-6 z-50 bg-rose-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<!-- Success Toast -->
|
||||
<div v-if="toast" class="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-up">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ toast }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface ValidationRule {
|
||||
id: number
|
||||
rule_key: string
|
||||
rule_value: number
|
||||
description: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
const toast = ref('')
|
||||
const lastSaved = ref('')
|
||||
|
||||
const rules = ref<ValidationRule[]>([])
|
||||
const originalValues = ref<Record<string, number>>({})
|
||||
const editedValues = ref<Record<string, number>>({})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return Object.keys(editedValues.value).length > 0
|
||||
})
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const tokenCookie = useCookie('access_token')
|
||||
const token = tokenCookie.value
|
||||
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
function showToast(msg: string) {
|
||||
toast.value = msg
|
||||
setTimeout(() => { toast.value = '' }, 3000)
|
||||
}
|
||||
|
||||
function onValueChange(ruleKey: string, rawValue: string) {
|
||||
const num = parseInt(rawValue, 10)
|
||||
if (isNaN(num)) return
|
||||
const clamped = Math.max(0, Math.min(100, num))
|
||||
editedValues.value[ruleKey] = clamped
|
||||
}
|
||||
|
||||
function hasRuleChanged(ruleKey: string): boolean {
|
||||
return ruleKey in editedValues.value
|
||||
}
|
||||
|
||||
function getOriginalValue(ruleKey: string): number {
|
||||
return originalValues.value[ruleKey] ?? 0
|
||||
}
|
||||
|
||||
function getProgressWidth(ruleKey: string): string {
|
||||
const val = editedValues.value[ruleKey] ?? originalValues.value[ruleKey] ?? 0
|
||||
return val + '%'
|
||||
}
|
||||
|
||||
function getValueBadgeClass(val: number): string {
|
||||
if (val >= 80) return 'bg-emerald-500/20 text-emerald-400'
|
||||
if (val >= 50) return 'bg-amber-500/20 text-amber-400'
|
||||
return 'bg-slate-500/20 text-slate-400'
|
||||
}
|
||||
|
||||
function getProgressBarClass(val: number): string {
|
||||
if (val >= 80) return 'bg-emerald-500'
|
||||
if (val >= 50) return 'bg-amber-500'
|
||||
return 'bg-slate-500'
|
||||
}
|
||||
|
||||
async function fetchRules() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const data = await $fetch<ValidationRule[]>('/api/v1/admin/gamification/validation-rules', {
|
||||
headers: getHeaders(),
|
||||
})
|
||||
rules.value = data
|
||||
// Store original values for change tracking
|
||||
originalValues.value = {}
|
||||
editedValues.value = {}
|
||||
for (const rule of data) {
|
||||
originalValues.value[rule.rule_key] = rule.rule_value
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch validation rules:', e)
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRules() {
|
||||
saving.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
const payload: Record<string, number> = {}
|
||||
for (const [key, val] of Object.entries(editedValues.value)) {
|
||||
payload[key] = val
|
||||
}
|
||||
|
||||
await $fetch('/api/v1/admin/gamification/validation-rules', {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: { rules: payload },
|
||||
})
|
||||
|
||||
// Update original values after save
|
||||
for (const [key, val] of Object.entries(editedValues.value)) {
|
||||
originalValues.value[key] = val
|
||||
}
|
||||
editedValues.value = {}
|
||||
lastSaved.value = new Date().toLocaleString('hu-HU')
|
||||
showToast(t('gamification.validation_rules.rules_updated'))
|
||||
} catch (e: any) {
|
||||
formError.value = t('gamification.validation_rules.save_error')
|
||||
console.error('Failed to save validation rules:', e)
|
||||
setTimeout(() => { formError.value = '' }, 4000)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRules()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user