2026.06.04 frontend építés közben
This commit is contained in:
197
old_maps/frontend_old/Archive/src/App.vue
Executable file
197
old_maps/frontend_old/Archive/src/App.vue
Executable file
@@ -0,0 +1,197 @@
|
||||
<script setup>
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import DailyQuizModal from '@/components/DailyQuizModal.vue'
|
||||
import QuickActionsFAB from '@/components/actions/QuickActionsFAB.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
themeStore.toggleTheme()
|
||||
}
|
||||
|
||||
// Legal modal state
|
||||
const showLegalModal = ref(false)
|
||||
const legalModalTitle = ref('')
|
||||
const legalModalContent = ref('')
|
||||
|
||||
const openLegalModal = (type) => {
|
||||
if (type === 'aszf') {
|
||||
legalModalTitle.value = 'Általános Szerződési Feltételek (ÁSZF)'
|
||||
legalModalContent.value = 'A jogi dokumentáció feltöltés alatt... A Service Finder szolgáltatás használatával Ön elfogadja az Általános Szerződési Feltételeket, amelyek meghatározzák a szolgáltatás használatának feltételeit, a felelősség korlátozását és a felhasználói jogokat.'
|
||||
} else if (type === 'adatkezeles') {
|
||||
legalModalTitle.value = 'Adatkezelési Tájékoztató (GDPR)'
|
||||
legalModalContent.value = 'A jogi dokumentáció feltöltés alatt... A Service Finder tiszteletben tartja az Ön adatvédelmét. E tájékoztató részletezi, hogyan gyűjtjük, tároljuk és kezeljük személyes adatait az Európai Unió Általános Adatvédelmi Rendelete (GDPR) és a vonatkozó magyar jogszabályok értelmében.'
|
||||
} else if (type === 'cookies') {
|
||||
legalModalTitle.value = 'Sütik (Cookies) Használata'
|
||||
legalModalContent.value = 'A jogi dokumentáció feltöltés alatt... Weboldalunk sütiket (cookies) használ a felhasználói élmény javítása, a funkciók működésének biztosítása és a forgalom elemzése érdekében. A sütik használatával kapcsolatos részletes információkat itt találja.'
|
||||
}
|
||||
showLegalModal.value = true
|
||||
}
|
||||
|
||||
const closeLegalModal = () => {
|
||||
showLegalModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['min-h-screen flex flex-col font-sans transition-all duration-500', themeStore.themeClasses.background, themeStore.themeClasses.text]">
|
||||
<!-- Premium Navigation -->
|
||||
<nav class="bg-gradient-to-r from-slate-800 to-slate-900 text-white p-4 shadow-xl flex justify-between items-center z-50 border-b border-slate-700/50 backdrop-blur-md">
|
||||
<div class="flex items-center gap-3 cursor-pointer group" @click="router.push('/')">
|
||||
<div class="p-2 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-500 group-hover:from-blue-600 group-hover:to-cyan-600 transition-all duration-200">
|
||||
<span class="text-2xl">🚗</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold text-xl tracking-tight">Service Finder</span>
|
||||
<span class="text-xs text-slate-300">Premium Vehicle Management</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-x-6 hidden md:flex items-center">
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<router-link to="/" class="nav-link text-slate-200 hover:text-white font-medium transition-colors duration-200 hover:scale-105">Dashboard</router-link>
|
||||
<router-link to="/expenses" class="nav-link text-slate-200 hover:text-white font-medium transition-colors duration-200 hover:scale-105">Költségek</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin" class="text-amber-300 font-bold hover:text-amber-200 transition-colors duration-200 hover:scale-105">⚙️ Admin</router-link>
|
||||
|
||||
<!-- User Role Badge -->
|
||||
<div v-if="authStore.isTester" class="px-3 py-1.5 bg-gradient-to-r from-purple-600 to-pink-600 rounded-lg text-xs font-bold text-white border border-purple-500/50 shadow-md animate-pulse">
|
||||
🧪 {{ authStore.displayName }}
|
||||
</div>
|
||||
<div v-else-if="authStore.isAdmin" class="px-3 py-1.5 bg-gradient-to-r from-amber-600 to-orange-600 rounded-lg text-xs font-bold text-white border border-amber-500/50 shadow-md">
|
||||
⚡ {{ authStore.displayName }}
|
||||
</div>
|
||||
|
||||
<button @click="toggleTheme" :class="['px-4 py-2.5 rounded-xl text-sm transition-all duration-200 active:scale-95 border shadow-md hover:shadow-lg', themeStore.isLuxury ? 'bg-gradient-to-r from-amber-700 to-amber-800 border-amber-600/50 text-white hover:from-amber-800 hover:to-amber-900' : 'bg-gradient-to-r from-orange-700 to-orange-800 border-orange-600/50 text-white hover:from-orange-800 hover:to-orange-900']">
|
||||
{{ themeStore.isLuxury ? '🏛️ Luxury' : '🔧 Workshop' }}
|
||||
</button>
|
||||
<button @click="handleLogout" class="bg-gradient-to-r from-slate-700 to-slate-800 px-5 py-2.5 rounded-xl text-sm hover:from-slate-800 hover:to-slate-900 transition-all duration-200 active:scale-95 border border-slate-600/50 shadow-md hover:shadow-lg">
|
||||
Kijelentkezés
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login" class="text-slate-200 hover:text-white font-medium transition-colors duration-200">Bejelentkezés</router-link>
|
||||
<router-link to="/register" class="bg-gradient-to-r from-blue-600 to-cyan-600 px-5 py-2.5 rounded-xl text-sm hover:from-blue-700 hover:to-cyan-700 transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg">
|
||||
Regisztráció
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button class="md:hidden p-2 rounded-lg bg-slate-700/50 hover:bg-slate-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 p-4 md:p-6 max-w-7xl mx-auto w-full">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Daily Quiz Modal -->
|
||||
<DailyQuizModal v-if="authStore.isLoggedIn && route.path !== '/login' && route.path !== '/register' && route.path !== '/debug'" />
|
||||
|
||||
<!-- Quick Actions FAB -->
|
||||
<QuickActionsFAB v-if="authStore.isLoggedIn && route.path !== '/login' && route.path !== '/register' && route.path !== '/debug'" />
|
||||
|
||||
<!-- Legal Modal -->
|
||||
<div v-if="showLegalModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-all duration-300">
|
||||
<div class="bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl shadow-2xl border border-slate-700/50 w-full max-w-2xl mx-4 overflow-hidden transform transition-all duration-300 scale-100">
|
||||
<div class="p-6 border-b border-slate-700/50">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-xl font-bold text-white">{{ legalModalTitle }}</h3>
|
||||
<button @click="closeLegalModal" class="text-slate-400 hover:text-white transition-colors duration-200 p-2 rounded-lg hover:bg-slate-700/50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<div class="prose prose-invert max-w-none">
|
||||
<p class="text-slate-300 mb-4">{{ legalModalContent }}</p>
|
||||
<div class="bg-slate-800/50 rounded-xl p-4 border border-slate-700/50 mt-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-r from-blue-500 to-cyan-500 flex items-center justify-center">
|
||||
<span class="text-white font-bold">ℹ️</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-400">Ez egy helykitöltő szöveg. A végleges jogi dokumentáció a termék bevezetésével együtt kerül feltöltésre.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-slate-700/50 bg-slate-900/50">
|
||||
<div class="flex justify-end">
|
||||
<button @click="closeLegalModal" class="px-6 py-3 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white font-medium rounded-xl transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg">
|
||||
Bezárás
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Premium Footer -->
|
||||
<footer class="mt-auto bg-gradient-to-r from-slate-800 to-slate-900 text-slate-300 p-6 border-t border-slate-700/50">
|
||||
<div class="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center">
|
||||
<div class="mb-4 md:mb-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-2xl">🚗</span>
|
||||
<span class="font-bold text-white">Service Finder</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400">Premium vehicle management for individuals and businesses</p>
|
||||
</div>
|
||||
<div class="flex gap-6">
|
||||
<button @click="openLegalModal('aszf')" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm hover:underline">ÁSZF</button>
|
||||
<button @click="openLegalModal('adatkezeles')" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm hover:underline">Adatkezelési Tájékoztató</button>
|
||||
<button @click="openLegalModal('cookies')" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm hover:underline">Sütik</button>
|
||||
<a href="#" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm">Kapcsolat</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto mt-6 pt-6 border-t border-slate-700/50 text-center text-xs text-slate-500">
|
||||
© 2026 Service Finder. Minden jog fenntartva. Gépjármű-rajongók számára készült precízióval.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
position: relative;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, #3b82f6, #06b6d4);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: color, background-color, border-color, transform, box-shadow;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
</style>
|
||||
1
old_maps/frontend_old/Archive/src/assets/vue.svg
Executable file
1
old_maps/frontend_old/Archive/src/assets/vue.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
231
old_maps/frontend_old/Archive/src/components/DailyQuizModal.vue
Normal file
231
old_maps/frontend_old/Archive/src/components/DailyQuizModal.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useQuizStore } from '@/stores/quizStore'
|
||||
|
||||
const quizStore = useQuizStore()
|
||||
|
||||
const showModal = ref(false)
|
||||
const currentQuestionIndex = ref(0)
|
||||
const selectedOption = ref(null)
|
||||
const showResult = ref(false)
|
||||
const isCorrect = ref(false)
|
||||
const resultExplanation = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const currentQuestion = computed(() => quizStore.questions[currentQuestionIndex.value])
|
||||
const totalQuestions = computed(() => quizStore.totalQuestions)
|
||||
const progress = computed(() => ((currentQuestionIndex.value + 1) / totalQuestions.value) * 100)
|
||||
|
||||
// Auto-show modal after 3-5 seconds if canPlayToday
|
||||
onMounted(() => {
|
||||
if (quizStore.canPlayToday) {
|
||||
setTimeout(() => {
|
||||
openModal()
|
||||
}, 3500) // 3.5 seconds
|
||||
}
|
||||
})
|
||||
|
||||
async function openModal() {
|
||||
if (!quizStore.canPlayToday) {
|
||||
alert('Már játszottál ma! Holnap próbáld újra.')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
// Fetch daily quiz questions from API
|
||||
await quizStore.fetchDailyQuiz()
|
||||
resetQuiz()
|
||||
showModal.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to load daily quiz:', error)
|
||||
alert('Hiba történt a kvíz betöltése közben. Próbáld újra később.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
function resetQuiz() {
|
||||
currentQuestionIndex.value = 0
|
||||
selectedOption.value = null
|
||||
showResult.value = false
|
||||
isCorrect.value = false
|
||||
resultExplanation.value = ''
|
||||
}
|
||||
|
||||
async function selectOption(optionIndex) {
|
||||
if (showResult.value) return
|
||||
selectedOption.value = optionIndex
|
||||
|
||||
try {
|
||||
const result = await quizStore.answerQuestion(currentQuestion.value.id, optionIndex)
|
||||
isCorrect.value = result.is_correct
|
||||
resultExplanation.value = result.explanation
|
||||
showResult.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to submit answer:', error)
|
||||
alert('Hiba történt a válasz beküldése közben.')
|
||||
}
|
||||
}
|
||||
|
||||
function nextQuestion() {
|
||||
if (currentQuestionIndex.value < totalQuestions.value - 1) {
|
||||
currentQuestionIndex.value++
|
||||
selectedOption.value = null
|
||||
showResult.value = false
|
||||
} else {
|
||||
finishQuiz()
|
||||
}
|
||||
}
|
||||
|
||||
async function finishQuiz() {
|
||||
try {
|
||||
await quizStore.completeDailyQuiz()
|
||||
showModal.value = false
|
||||
alert(`Kvíz befejezve! Szerezttél ${quizStore.userPoints} pontot. Streak: ${quizStore.currentStreak}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to complete quiz:', error)
|
||||
alert('Hiba történt a kvíz befejezése közben.')
|
||||
}
|
||||
}
|
||||
|
||||
function skipToday() {
|
||||
quizStore.completeDailyQuiz() // mark as played today
|
||||
closeModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showModal" class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="relative w-full max-w-2xl rounded-2xl bg-gradient-to-br from-blue-50 to-white shadow-2xl p-6 md:p-8 border border-blue-200">
|
||||
<!-- Close button -->
|
||||
<button @click="closeModal" class="absolute top-4 right-4 text-gray-500 hover:text-gray-800 text-2xl">
|
||||
×
|
||||
</button>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-r from-blue-500 to-purple-500 mb-4">
|
||||
<span class="text-3xl">🧠</span>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-gray-900">Napi Kvíz</h2>
|
||||
<p class="text-gray-600 mt-2">Teszteld tudásod és szerezz pontokat!</p>
|
||||
<div class="mt-4 flex items-center justify-between text-sm text-gray-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">Pontok:</span>
|
||||
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full">{{ quizStore.userPoints }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">Streak:</span>
|
||||
<span class="bg-amber-100 text-amber-800 px-3 py-1 rounded-full">{{ quizStore.currentStreak }} nap</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between text-sm text-gray-700 mb-2">
|
||||
<span>Kérdés {{ currentQuestionIndex + 1 }} / {{ totalQuestions }}</span>
|
||||
<span>{{ Math.round(progress) }}%</span>
|
||||
</div>
|
||||
<div class="h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-500" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-6">{{ currentQuestion.question }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
v-for="(option, idx) in currentQuestion.options"
|
||||
:key="idx"
|
||||
@click="selectOption(idx)"
|
||||
class="p-4 text-left rounded-xl border-2 transition-all duration-300"
|
||||
:class="{
|
||||
'border-blue-500 bg-blue-50': selectedOption === idx,
|
||||
'border-gray-300 hover:border-blue-400 hover:bg-blue-50': selectedOption === null && !showResult,
|
||||
'border-green-500 bg-green-50': showResult && idx === currentQuestion.correctAnswer,
|
||||
'border-red-300 bg-red-50': showResult && selectedOption === idx && !isCorrect,
|
||||
}"
|
||||
:disabled="showResult"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mr-3"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-800': selectedOption === idx && !showResult,
|
||||
'bg-green-100 text-green-800': showResult && idx === currentQuestion.correctAnswer,
|
||||
'bg-red-100 text-red-800': showResult && selectedOption === idx && !isCorrect,
|
||||
'bg-gray-100 text-gray-800': selectedOption !== idx && !showResult,
|
||||
}">
|
||||
{{ String.fromCharCode(65 + idx) }}
|
||||
</div>
|
||||
<span class="font-medium">{{ option }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result & Explanation -->
|
||||
<div v-if="showResult" class="mb-8 p-5 rounded-xl" :class="isCorrect ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="isCorrect ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'">
|
||||
{{ isCorrect ? '✅' : '❌' }}
|
||||
</div>
|
||||
<h4 class="text-xl font-bold" :class="isCorrect ? 'text-green-800' : 'text-red-800'">
|
||||
{{ isCorrect ? 'Helyes válasz!' : 'Sajnos nem talált!' }}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-gray-800">{{ resultExplanation }}</p>
|
||||
<div class="mt-4 text-sm text-gray-700">
|
||||
<span class="font-bold">Pontok:</span> {{ isCorrect ? '+10' : '0' }} |
|
||||
<span class="font-bold">Streak:</span> {{ isCorrect ? 'növelve' : 'nullázva' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
v-if="!showResult"
|
||||
@click="skipToday"
|
||||
class="flex-1 py-3 px-6 rounded-xl border-2 border-gray-300 text-gray-700 font-semibold hover:bg-gray-100 transition"
|
||||
>
|
||||
Emlékeztess később
|
||||
</button>
|
||||
<button
|
||||
v-if="showResult && currentQuestionIndex < totalQuestions - 1"
|
||||
@click="nextQuestion"
|
||||
class="flex-1 py-3 px-6 rounded-xl bg-gradient-to-r from-blue-500 to-purple-500 text-white font-bold hover:opacity-90 transition"
|
||||
>
|
||||
Következő kérdés
|
||||
</button>
|
||||
<button
|
||||
v-if="showResult && currentQuestionIndex === totalQuestions - 1"
|
||||
@click="finishQuiz"
|
||||
class="flex-1 py-3 px-6 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 text-white font-bold hover:opacity-90 transition"
|
||||
>
|
||||
Kvíz befejezése
|
||||
</button>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="flex-1 py-3 px-6 rounded-xl bg-gray-200 text-gray-800 font-semibold hover:bg-gray-300 transition"
|
||||
>
|
||||
Bezárás
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer note -->
|
||||
<div class="mt-8 text-center text-sm text-gray-500">
|
||||
A napi kvíz csak egyszer játszható 24 óránként. Streaked növeléséhez válaszolj helyesen minden nap!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional custom styles if needed */
|
||||
</style>
|
||||
161
old_maps/frontend_old/Archive/src/components/ProfileSelector.vue
Normal file
161
old_maps/frontend_old/Archive/src/components/ProfileSelector.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<h1 class="text-4xl font-bold text-center mb-4 text-slate-900">Welcome to Service Finder</h1>
|
||||
<p class="text-lg text-slate-600 text-center mb-12 max-w-2xl mx-auto">
|
||||
Choose your experience based on how you use vehicles. Your selection will customize the dashboard and features.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
|
||||
<!-- Private Garage Card - Vibrant gradient border & playful -->
|
||||
<div
|
||||
class="relative rounded-3xl p-8 cursor-pointer transition-all duration-300 hover:scale-[1.02] active:scale-95 bg-gradient-to-br from-white to-slate-50/80 backdrop-blur-sm border border-slate-200/60"
|
||||
:class="{
|
||||
'selected shadow-2xl ring-4 ring-opacity-30 ring-amber-400/50': isPrivateGarage,
|
||||
'private-garage': true
|
||||
}"
|
||||
@click="selectMode('private_garage')"
|
||||
>
|
||||
<!-- Vibrant gradient border effect -->
|
||||
<div v-if="isPrivateGarage" class="absolute -inset-0.5 bg-gradient-to-r from-amber-400 via-orange-400 to-pink-400 rounded-3xl blur-sm opacity-70 -z-10"></div>
|
||||
|
||||
<div class="mb-6 p-4 rounded-2xl inline-flex bg-gradient-to-br from-amber-100 to-orange-100 text-amber-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-4 text-slate-900">Private Garage</h2>
|
||||
<p class="text-slate-700 mb-6 leading-relaxed">
|
||||
Perfect for individual vehicle owners. Track expenses, maintenance, and get personalized recommendations for your personal cars, motorcycles, or recreational vehicles.
|
||||
</p>
|
||||
<ul class="mb-8 space-y-3 card-features-private">
|
||||
<li class="flex items-center text-sm text-slate-700">Personal vehicle management</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Expense tracking & budgeting</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Maintenance reminders</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Fuel efficiency analytics</li>
|
||||
</ul>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="px-3 py-1.5 rounded-full text-xs font-semibold bg-gradient-to-r from-amber-100 to-orange-100 text-amber-800 border border-amber-200/60">For Individuals</span>
|
||||
<button
|
||||
class="px-6 py-2.5 rounded-lg font-medium transition-all duration-200 bg-gradient-to-r from-amber-500 to-orange-500 text-white hover:from-amber-600 hover:to-orange-600 active:scale-95 shadow-md hover:shadow-lg"
|
||||
:class="{ 'ring-2 ring-amber-300 ring-offset-2': isPrivateGarage }"
|
||||
>
|
||||
{{ isPrivateGarage ? '✓ Selected' : 'Select' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corporate Fleet Card - Minimalist sharp design -->
|
||||
<div
|
||||
class="relative rounded-3xl p-8 cursor-pointer transition-all duration-300 hover:scale-[1.02] active:scale-95 bg-gradient-to-br from-white to-slate-50/80 backdrop-blur-sm border border-slate-200/60"
|
||||
:class="{
|
||||
'selected shadow-2xl ring-4 ring-opacity-30 ring-blue-400/50': isCorporateFleet,
|
||||
'corporate-fleet': true
|
||||
}"
|
||||
@click="selectMode('corporate_fleet')"
|
||||
>
|
||||
<!-- Sharp business accent line -->
|
||||
<div v-if="isCorporateFleet" class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-t-3xl"></div>
|
||||
|
||||
<div class="mb-6 p-4 rounded-2xl inline-flex bg-gradient-to-br from-blue-50 to-cyan-50 text-blue-700 border border-blue-200/40">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-4 text-slate-900">Corporate Fleet</h2>
|
||||
<p class="text-slate-700 mb-6 leading-relaxed">
|
||||
Designed for fleet managers and businesses. Monitor multiple vehicles, optimize TCO (Total Cost of Ownership), and manage service schedules across your entire fleet.
|
||||
</p>
|
||||
<ul class="mb-8 space-y-3 card-features-corporate">
|
||||
<li class="flex items-center text-sm text-slate-700">Multi-vehicle fleet management</li>
|
||||
<li class="flex items-center text-sm text-slate-700">TCO & ROI analytics</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Driver assignment & reporting</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Bulk service scheduling</li>
|
||||
</ul>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="px-3 py-1.5 rounded-full text-xs font-semibold bg-gradient-to-r from-blue-50 to-cyan-50 text-blue-800 border border-blue-200/60">For Businesses</span>
|
||||
<button
|
||||
class="px-6 py-2.5 rounded-lg font-medium transition-all duration-200 bg-gradient-to-r from-blue-600 to-cyan-600 text-white hover:from-blue-700 hover:to-cyan-700 active:scale-95 shadow-md hover:shadow-lg"
|
||||
:class="{ 'ring-2 ring-blue-300 ring-offset-2': isCorporateFleet }"
|
||||
>
|
||||
{{ isCorporateFleet ? '✓ Selected' : 'Select' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row justify-between items-center p-6 border-t border-slate-200/60">
|
||||
<p class="text-sm text-slate-500">
|
||||
You can change this later from the header menu.
|
||||
</p>
|
||||
<button
|
||||
class="mt-4 md:mt-0 px-8 py-3.5 bg-gradient-to-r from-blue-600 to-cyan-600 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-cyan-700 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center shadow-lg hover:shadow-xl active:scale-95"
|
||||
@click="continueToDashboard"
|
||||
:disabled="!mode"
|
||||
>
|
||||
Continue to Dashboard
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L12.586 11H5a1 1 0 110-2h7.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const router = useRouter()
|
||||
|
||||
const { mode, isPrivateGarage, isCorporateFleet } = storeToRefs(appModeStore)
|
||||
|
||||
function selectMode(newMode) {
|
||||
appModeStore.setMode(newMode)
|
||||
}
|
||||
|
||||
function continueToDashboard() {
|
||||
console.log('ProfileSelector: Continuing to dashboard with mode', mode.value)
|
||||
try {
|
||||
router.push('/')
|
||||
console.log('ProfileSelector: Redirect to dashboard successful')
|
||||
} catch (error) {
|
||||
console.error('ProfileSelector: Failed to redirect to dashboard:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Keep only pseudo-element and media query styles */
|
||||
.card-features-private li::before,
|
||||
.card-features-corporate li::before {
|
||||
content: '✓';
|
||||
margin-right: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-features-private li::before {
|
||||
color: #f59e0b; /* amber-500 */
|
||||
}
|
||||
|
||||
.card-features-corporate li::before {
|
||||
color: #06b6d4; /* cyan-500 */
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.rounded-3xl.p-8 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.flex.flex-col.md\:flex-row {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useExpenseStore } from '@/stores/expenseStore'
|
||||
import { useGarageStore } from '@/stores/garageStore'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const expenseStore = useExpenseStore()
|
||||
const garageStore = useGarageStore()
|
||||
|
||||
// Use selected vehicle from garage store, or default to first vehicle
|
||||
const selectedAssetId = ref(garageStore.selectedVehicle?.id || garageStore.vehicles[0]?.id || '')
|
||||
|
||||
const amount = ref('')
|
||||
const category = ref('fuel')
|
||||
const date = ref(new Date().toISOString().split('T')[0]) // today
|
||||
const description = ref('')
|
||||
const mileage = ref('')
|
||||
const isLoading = ref(false)
|
||||
const showDraftLimitModal = ref(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedAssetId.value) {
|
||||
alert('Nincs kiválasztott jármű. Kérjük, először adj hozzá egy járművet.')
|
||||
return
|
||||
}
|
||||
if (!amount.value || !date.value) {
|
||||
alert('Kérjük, töltsd ki a kötelező mezőket.')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const expenseData = {
|
||||
asset_id: selectedAssetId.value,
|
||||
cost_type: category.value, // fuel, service, tax, insurance
|
||||
amount_local: parseFloat(amount.value),
|
||||
currency_local: 'HUF', // default, could be dynamic
|
||||
date: new Date(date.value).toISOString(),
|
||||
description: description.value,
|
||||
mileage_at_cost: mileage.value ? parseInt(mileage.value) : null,
|
||||
data: {}
|
||||
}
|
||||
await expenseStore.createExpense(expenseData)
|
||||
|
||||
// Success
|
||||
alert('Költség sikeresen mentve!')
|
||||
|
||||
// Reset form
|
||||
amount.value = ''
|
||||
category.value = 'fuel'
|
||||
date.value = new Date().toISOString().split('T')[0]
|
||||
description.value = ''
|
||||
mileage.value = ''
|
||||
|
||||
// Close modal
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Error saving expense:', error)
|
||||
if (error.message === 'DRAFT_LIMIT_REACHED') {
|
||||
// Show custom modal for draft limit
|
||||
showDraftLimitModal.value = true
|
||||
} else {
|
||||
alert(`Hiba történt a mentés során: ${expenseStore.error || error.message}`)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const closeDraftLimitModal = () => {
|
||||
showDraftLimitModal.value = false
|
||||
}
|
||||
|
||||
const openEditVehicle = () => {
|
||||
// TODO: Implement opening edit vehicle modal
|
||||
// For now, just close the draft limit modal and maybe show a message
|
||||
alert('Edit vehicle functionality to be implemented')
|
||||
closeDraftLimitModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 p-4" @click.self="closeModal">
|
||||
<!-- Modal Container -->
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">Költség / Üzemanyag hozzáadása</h2>
|
||||
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Asset selection (if multiple vehicles) -->
|
||||
<div v-if="garageStore.vehicles.length > 0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Jármű</label>
|
||||
<select
|
||||
v-model="selectedAssetId"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
>
|
||||
<option v-for="vehicle in garageStore.vehicles" :key="vehicle.id" :value="vehicle.id">
|
||||
{{ vehicle.name || vehicle.license_plate || vehicle.vin }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-else class="text-sm text-amber-600 bg-amber-50 p-3 rounded-lg">
|
||||
Nincs még járműved. Először adj hozzá egy járművet a "Jármű Hozzáadása" gombbal.
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Összeg (HUF)</label>
|
||||
<input
|
||||
v-model="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Kategória</label>
|
||||
<select
|
||||
v-model="category"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
>
|
||||
<option value="fuel">Üzemanyag</option>
|
||||
<option value="service">Szerviz / Karbantartás</option>
|
||||
<option value="tax">Adó / Díj</option>
|
||||
<option value="insurance">Biztosítás</option>
|
||||
<option value="parking">Parkolás</option>
|
||||
<option value="toll">Útdíj</option>
|
||||
<option value="other">Egyéb</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Dátum</label>
|
||||
<input
|
||||
v-model="date"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mileage (optional) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Kilométeróra állása (opcionális)</label>
|
||||
<input
|
||||
v-model="mileage"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="pl. 123456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Leírás (opcionális)</label>
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="3"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="Pl.: Tankolás, olajcsere..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Mégse
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || !selectedAssetId"
|
||||
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="isLoading">Mentés...</span>
|
||||
<span v-else>Mentés</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draft Limit Modal -->
|
||||
<div v-if="showDraftLimitModal" class="fixed inset-0 z-[200] flex items-center justify-center bg-black bg-opacity-70 p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-yellow-100 text-yellow-600 mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-2">You have reached the limit for unregistered vehicles! 🔒</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Please edit this vehicle and provide the VIN (Chassis Number) or exact Catalog ID to unlock full expense tracking.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="closeDraftLimitModal"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="openEditVehicle"
|
||||
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
|
||||
>
|
||||
Edit Vehicle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional custom styles if needed */
|
||||
</style>
|
||||
@@ -0,0 +1,404 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useGarageStore } from '../../stores/garageStore'
|
||||
import { useAuthStore } from '../../stores/authStore'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const garageStore = useGarageStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Form fields
|
||||
const make = ref('')
|
||||
const model = ref('')
|
||||
const generation = ref('')
|
||||
const engine = ref('')
|
||||
const engineId = ref(null) // NEW: Store the engine/catalog ID
|
||||
const licensePlate = ref('')
|
||||
const year = ref('')
|
||||
const fuelType = ref('petrol')
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Catalog data
|
||||
const makes = ref([])
|
||||
const models = ref([])
|
||||
const generations = ref([])
|
||||
const engines = ref([])
|
||||
|
||||
// Loading states
|
||||
const loadingMakes = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const loadingGenerations = ref(false)
|
||||
const loadingEngines = ref(false)
|
||||
|
||||
// Search filters
|
||||
const makeSearch = ref('')
|
||||
const modelSearch = ref('')
|
||||
const generationSearch = ref('')
|
||||
const engineSearch = ref('')
|
||||
|
||||
// Filtered lists based on search
|
||||
const filteredMakes = ref([])
|
||||
const filteredModels = ref([])
|
||||
const filteredGenerations = ref([])
|
||||
const filteredEngines = ref([])
|
||||
|
||||
// Fetch makes on component mount
|
||||
onMounted(async () => {
|
||||
await fetchMakes()
|
||||
})
|
||||
|
||||
// Watch for search changes
|
||||
watch(makeSearch, (val) => {
|
||||
filteredMakes.value = makes.value.filter(m =>
|
||||
m.toLowerCase().includes(val.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
watch(modelSearch, (val) => {
|
||||
filteredModels.value = models.value.filter(m =>
|
||||
m.toLowerCase().includes(val.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
watch(generationSearch, (val) => {
|
||||
filteredGenerations.value = generations.value.filter(g =>
|
||||
g.toLowerCase().includes(val.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
watch(engineSearch, (val) => {
|
||||
filteredEngines.value = engines.value.filter(e =>
|
||||
e.variant.toLowerCase().includes(val.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// Watch for make selection to fetch models
|
||||
watch(make, async (newMake) => {
|
||||
if (newMake) {
|
||||
await fetchModels(newMake)
|
||||
model.value = ''
|
||||
generation.value = ''
|
||||
engine.value = ''
|
||||
engineId.value = null
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
} else {
|
||||
models.value = []
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for model selection to fetch generations
|
||||
watch(model, async (newModel) => {
|
||||
if (newModel && make.value) {
|
||||
await fetchGenerations(make.value, newModel)
|
||||
generation.value = ''
|
||||
engine.value = ''
|
||||
engineId.value = null
|
||||
engines.value = []
|
||||
} else {
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for generation selection to fetch engines
|
||||
watch(generation, async (newGen) => {
|
||||
if (newGen && make.value && model.value) {
|
||||
await fetchEngines(make.value, model.value, newGen)
|
||||
engine.value = ''
|
||||
engineId.value = null
|
||||
} else {
|
||||
engines.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for engine selection to auto-fill fuel type and capture ID
|
||||
watch(engine, (newEngine) => {
|
||||
if (newEngine) {
|
||||
const selectedEngine = engines.value.find(e => e.variant === newEngine)
|
||||
if (selectedEngine) {
|
||||
// Store the engine ID for API call
|
||||
engineId.value = selectedEngine.id
|
||||
// Auto-fill fuel type if available
|
||||
if (selectedEngine.fuel_type) {
|
||||
fuelType.value = selectedEngine.fuel_type.toLowerCase()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
engineId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// API calls
|
||||
async function fetchMakes() {
|
||||
loadingMakes.value = true
|
||||
try {
|
||||
const token = authStore.token
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
|
||||
const response = await fetch(`${apiBaseUrl}/catalog/makes`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch makes: ${response.status}`)
|
||||
makes.value = await response.json()
|
||||
filteredMakes.value = makes.value
|
||||
} catch (err) {
|
||||
console.error('Error fetching makes:', err)
|
||||
error.value = 'Could not load makes. Please try again.'
|
||||
} finally {
|
||||
loadingMakes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchModels(makeName) {
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const token = authStore.token
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
|
||||
const response = await fetch(`${apiBaseUrl}/catalog/models?make=${encodeURIComponent(makeName)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch models: ${response.status}`)
|
||||
models.value = await response.json()
|
||||
filteredModels.value = models.value
|
||||
} catch (err) {
|
||||
console.error('Error fetching models:', err)
|
||||
error.value = 'Could not load models for this make.'
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGenerations(makeName, modelName) {
|
||||
loadingGenerations.value = true
|
||||
try {
|
||||
const token = authStore.token
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
|
||||
const response = await fetch(`${apiBaseUrl}/catalog/generations?make=${encodeURIComponent(makeName)}&model=${encodeURIComponent(modelName)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch generations: ${response.status}`)
|
||||
generations.value = await response.json()
|
||||
filteredGenerations.value = generations.value
|
||||
} catch (err) {
|
||||
console.error('Error fetching generations:', err)
|
||||
error.value = 'Could not load generations for this model.'
|
||||
} finally {
|
||||
loadingGenerations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEngines(makeName, modelName, genName) {
|
||||
loadingEngines.value = true
|
||||
try {
|
||||
const token = authStore.token
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
|
||||
const response = await fetch(`${apiBaseUrl}/catalog/engines?make=${encodeURIComponent(makeName)}&model=${encodeURIComponent(modelName)}&gen=${encodeURIComponent(genName)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch engines: ${response.status}`)
|
||||
const engineData = await response.json()
|
||||
engines.value = engineData.map(e => ({
|
||||
variant: e.variant,
|
||||
fuel_type: e.fuel_type,
|
||||
id: e.id
|
||||
}))
|
||||
filteredEngines.value = engines.value
|
||||
} catch (err) {
|
||||
console.error('Error fetching engines:', err)
|
||||
error.value = 'Could not load engines for this generation.'
|
||||
} finally {
|
||||
loadingEngines.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = null
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Prepare vehicle data for the API
|
||||
const vehicleData = {
|
||||
make: make.value,
|
||||
model: model.value,
|
||||
licensePlate: licensePlate.value,
|
||||
year: parseInt(year.value) || new Date().getFullYear(),
|
||||
fuelType: fuelType.value,
|
||||
generation: generation.value,
|
||||
engine: engine.value,
|
||||
// Include the catalog ID (engine ID) for API
|
||||
catalogId: engineId.value,
|
||||
// Include organization ID (use active organization ID)
|
||||
organizationId: authStore.activeOrgId
|
||||
}
|
||||
|
||||
// Call the garage store to add vehicle
|
||||
await garageStore.addVehicle(vehicleData)
|
||||
|
||||
// Show success message
|
||||
alert('Sikeres mentés! Jármű hozzáadva.')
|
||||
|
||||
// Reset form
|
||||
make.value = ''
|
||||
model.value = ''
|
||||
generation.value = ''
|
||||
engine.value = ''
|
||||
engineId.value = null
|
||||
licensePlate.value = ''
|
||||
year.value = ''
|
||||
fuelType.value = 'petrol'
|
||||
makes.value = []
|
||||
models.value = []
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
|
||||
// Close modal
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
console.error('Error adding vehicle:', err)
|
||||
error.value = err.message || 'Ismeretlen hiba történt'
|
||||
alert(`Hiba: ${error.value}`)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 p-4" @click.self="closeModal">
|
||||
<!-- Modal Container -->
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">Jármű hozzáadása</h2>
|
||||
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Make -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Gyártó</label>
|
||||
<input
|
||||
v-model="make"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
|
||||
placeholder="Pl.: Toyota"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Model -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Modell</label>
|
||||
<input
|
||||
v-model="model"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
|
||||
placeholder="Pl.: Corolla"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- License Plate -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Rendszám</label>
|
||||
<input
|
||||
v-model="licensePlate"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
|
||||
placeholder="Pl.: ABC-123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Year and Fuel Type in a grid -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Year -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Évjárat</label>
|
||||
<input
|
||||
v-model="year"
|
||||
type="number"
|
||||
min="1900"
|
||||
:max="new Date().getFullYear() + 1"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
|
||||
placeholder="2023"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Üzemanyag típus</label>
|
||||
<select
|
||||
v-model="fuelType"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900"
|
||||
>
|
||||
<option value="petrol">Benzin</option>
|
||||
<option value="diesel">Dízel</option>
|
||||
<option value="electric">Elektromos</option>
|
||||
<option value="hybrid">Hibrid</option>
|
||||
<option value="lpg">LPG</option>
|
||||
<option value="other">Egyéb</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
:disabled="isLoading"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Mégse
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
class="flex-1 px-4 py-3 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<span v-if="isLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-white border-r-transparent mr-2"></span>
|
||||
{{ isLoading ? 'Feldolgozás...' : 'Jármű hozzáadása' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional custom styles if needed */
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const serviceType = ref('maintenance')
|
||||
const location = ref('')
|
||||
const urgency = ref('medium')
|
||||
|
||||
const handleSubmit = () => {
|
||||
// In a real app, you would call an API here
|
||||
console.log('Service search submitted:', {
|
||||
serviceType: serviceType.value,
|
||||
location: location.value,
|
||||
urgency: urgency.value
|
||||
})
|
||||
|
||||
// Show success message
|
||||
alert('Szerviz keresés elindítva! Hamarosan értesítünk.')
|
||||
|
||||
// Reset form
|
||||
serviceType.value = 'maintenance'
|
||||
location.value = ''
|
||||
urgency.value = 'medium'
|
||||
|
||||
// Close modal
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 p-4" @click.self="closeModal">
|
||||
<!-- Modal Container -->
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">Szerviz Keresése</h2>
|
||||
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Service Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Szerviz típusa</label>
|
||||
<select
|
||||
v-model="serviceType"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
>
|
||||
<option value="maintenance">Általános karbantartás</option>
|
||||
<option value="repair">Javítás</option>
|
||||
<option value="diagnostic">Diagnosztika</option>
|
||||
<option value="tire">Gumiszerviz</option>
|
||||
<option value="oil">Olajcsere</option>
|
||||
<option value="brake">Fékrendszer</option>
|
||||
<option value="other">Egyéb</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Helyszín (város/irányítószám)</label>
|
||||
<input
|
||||
v-model="location"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="Pl.: Budapest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Urgency -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Sürgősség</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center">
|
||||
<input v-model="urgency" type="radio" value="low" class="mr-2">
|
||||
<span class="text-gray-700">Alacsony</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input v-model="urgency" type="radio" value="medium" class="mr-2">
|
||||
<span class="text-gray-700">Közepes</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input v-model="urgency" type="radio" value="high" class="mr-2">
|
||||
<span class="text-gray-700">Magas</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Részletes leírás (opcionális)</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="Pl.: Motorhiba, féknyikorgás..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Mégse
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-4 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition"
|
||||
>
|
||||
Szerviz keresése
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional custom styles if needed */
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AddExpenseModal from './AddExpenseModal.vue'
|
||||
import SmartVehicleRegistration from './SmartVehicleRegistration.vue'
|
||||
import FindServiceModal from './FindServiceModal.vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const showExpenseModal = ref(false)
|
||||
const showSmartVehicleRegistration = ref(false)
|
||||
const showServiceModal = ref(false)
|
||||
|
||||
const toggleMenu = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const openExpenseModal = () => {
|
||||
showExpenseModal.value = true
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const openSmartVehicleRegistration = () => {
|
||||
showSmartVehicleRegistration.value = true
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const openServiceModal = () => {
|
||||
showServiceModal.value = true
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const closeExpenseModal = () => {
|
||||
showExpenseModal.value = false
|
||||
}
|
||||
|
||||
const closeSmartVehicleRegistration = () => {
|
||||
showSmartVehicleRegistration.value = false
|
||||
}
|
||||
|
||||
const closeServiceModal = () => {
|
||||
showServiceModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Floating Action Button -->
|
||||
<div class="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
||||
<!-- Action Menu (shown when open) -->
|
||||
<div v-if="isOpen" class="mb-4 space-y-3">
|
||||
<!-- Add Expense Button -->
|
||||
<button
|
||||
@click="openExpenseModal"
|
||||
class="flex items-center justify-end gap-3 bg-blue-600 hover:bg-blue-700 text-white px-4 py-3 rounded-full shadow-lg transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<span class="text-sm font-semibold">Költség / Üzemanyag</span>
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-blue-800 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Find Service Button -->
|
||||
<button
|
||||
@click="openServiceModal"
|
||||
class="flex items-center justify-end gap-3 bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-full shadow-lg transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<span class="text-sm font-semibold">Szerviz Keresése</span>
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-green-800 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Add Vehicle Button -->
|
||||
<button
|
||||
@click="openSmartVehicleRegistration"
|
||||
class="flex items-center justify-end gap-3 bg-purple-600 hover:bg-purple-700 text-white px-4 py-3 rounded-full shadow-lg transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<span class="text-sm font-semibold">Jármű Hozzáadása</span>
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-purple-800 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main FAB Button -->
|
||||
<button
|
||||
@click="toggleMenu"
|
||||
class="w-14 h-14 flex items-center justify-center bg-blue-700 hover:bg-blue-800 text-white rounded-full shadow-xl transition-all duration-200 transform hover:scale-110"
|
||||
:class="{ 'rotate-45': isOpen }"
|
||||
>
|
||||
<svg v-if="!isOpen" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<AddExpenseModal v-if="showExpenseModal" @close="closeExpenseModal" />
|
||||
<SmartVehicleRegistration v-if="showSmartVehicleRegistration" @close="closeSmartVehicleRegistration" />
|
||||
<FindServiceModal v-if="showServiceModal" @close="closeServiceModal" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Smooth transitions */
|
||||
button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="analytics-dashboard">
|
||||
<!-- Header with Mode Toggle -->
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between mb-8 p-6 bg-gradient-to-r from-gray-50 to-white rounded-2xl shadow-sm border border-gray-200">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Vehicle Analytics & TCO Dashboard</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
{{ isPrivateGarage ? 'Personal driving insights and fun achievements' : 'Corporate fleet performance and cost optimization' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-3 text-sm font-medium text-gray-700">View Mode:</span>
|
||||
<div class="relative inline-block w-64">
|
||||
<div class="bg-gray-100 rounded-xl p-1 flex">
|
||||
<button
|
||||
@click="setMode('private_garage')"
|
||||
:class="[
|
||||
'flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
isPrivateGarage
|
||||
? 'bg-white shadow text-gray-900'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="mr-2">🎮</span>
|
||||
<span>Fun Stats</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@click="setMode('corporate_fleet')"
|
||||
:class="[
|
||||
'flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
isCorporateFleet
|
||||
? 'bg-white shadow text-gray-900'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="mr-2">📊</span>
|
||||
<span>Business BI</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="toggleMode"
|
||||
class="px-4 py-3 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl font-medium hover:from-blue-600 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg flex items-center"
|
||||
>
|
||||
<span class="mr-2">🔄</span>
|
||||
Switch to {{ isPrivateGarage ? 'Business' : 'Fun' }} View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500 flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-green-500 mr-2"></div>
|
||||
<span>Live data updated {{ lastUpdated }}</span>
|
||||
<button @click="refreshData" class="ml-4 text-blue-600 hover:text-blue-800 flex items-center">
|
||||
<span class="mr-1">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode Indicator -->
|
||||
<div class="mb-6">
|
||||
<div v-if="isPrivateGarage" class="inline-flex items-center px-4 py-2 rounded-full bg-gradient-to-r from-blue-100 to-indigo-100 text-blue-800">
|
||||
<span class="mr-2">🎯</span>
|
||||
<span class="font-medium">Private Garage Mode</span>
|
||||
<span class="ml-2 text-sm">Personal insights and achievements</span>
|
||||
</div>
|
||||
<div v-else class="inline-flex items-center px-4 py-2 rounded-full bg-gradient-to-r from-green-100 to-emerald-100 text-green-800">
|
||||
<span class="mr-2">🏢</span>
|
||||
<span class="font-medium">Corporate Fleet Mode</span>
|
||||
<span class="ml-2 text-sm">Business intelligence and TCO analysis</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Component -->
|
||||
<div class="mt-6">
|
||||
<component :is="currentComponent" />
|
||||
</div>
|
||||
|
||||
<!-- Footer Notes -->
|
||||
<div class="mt-12 pt-6 border-t border-gray-200">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-gray-50 p-4 rounded-xl">
|
||||
<h4 class="font-semibold text-gray-800 mb-2">📈 Data Sources</h4>
|
||||
<p class="text-sm text-gray-600">Vehicle telemetry, fuel receipts, maintenance records, and insurance data aggregated in real-time.</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-xl">
|
||||
<h4 class="font-semibold text-gray-800 mb-2">🎯 Key Metrics</h4>
|
||||
<p class="text-sm text-gray-600">TCO (Total Cost of Ownership), Cost per km, Fuel efficiency, Utilization rate, and Environmental impact.</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-xl">
|
||||
<h4 class="font-semibold text-gray-800 mb-2">🔄 Auto-Sync</h4>
|
||||
<p class="text-sm text-gray-600">Data updates every 24 hours. Manual refresh available. Historical data retained for 36 months.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import FunStats from './FunStats.vue'
|
||||
import BusinessBI from './BusinessBI.vue'
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const { mode, isPrivateGarage, isCorporateFleet, toggleMode, setMode } = appModeStore
|
||||
|
||||
const lastUpdated = ref('just now')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const currentComponent = shallowRef(FunStats)
|
||||
|
||||
// Watch mode changes and update component
|
||||
import { watch } from 'vue'
|
||||
watch(() => mode.value, (newMode) => {
|
||||
if (newMode === 'private_garage') {
|
||||
currentComponent.value = FunStats
|
||||
} else {
|
||||
currentComponent.value = BusinessBI
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const refreshData = () => {
|
||||
isLoading.value = true
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
const now = new Date()
|
||||
lastUpdated.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
isLoading.value = false
|
||||
|
||||
// Show success notification
|
||||
const event = new CustomEvent('show-toast', {
|
||||
detail: {
|
||||
message: 'Analytics data refreshed successfully',
|
||||
type: 'success'
|
||||
}
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}, 800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-dashboard {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Smooth transitions for mode switching */
|
||||
.component-enter-active,
|
||||
.component-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.component-enter-from,
|
||||
.component-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<div class="business-bi">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6">📊 Business Intelligence Dashboard</h2>
|
||||
|
||||
<!-- Key Metrics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Fleet Size</p>
|
||||
<p class="text-3xl font-bold text-gray-800">{{ businessMetrics.fleetSize }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-2xl text-blue-600">🚗</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">Active vehicles</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Total Monthly Cost</p>
|
||||
<p class="text-3xl font-bold text-gray-800">€{{ formatNumber(businessMetrics.totalMonthlyCost) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-2xl text-green-600">💰</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">All expenses combined</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Avg Cost per Km</p>
|
||||
<p class="text-3xl font-bold text-gray-800">€{{ businessMetrics.averageCostPerKm.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-2xl text-purple-600">📈</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">Operating efficiency</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Utilization Rate</p>
|
||||
<p class="text-3xl font-bold text-gray-800">{{ businessMetrics.utilizationRate }}%</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-2xl text-amber-600">⚡</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">Fleet activity</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<!-- Monthly Costs Chart -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Monthly Cost Breakdown (Last 6 Months)</h3>
|
||||
<div class="h-80">
|
||||
<canvas ref="monthlyCostsChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 bg-blue-500 rounded-full mr-2"></div>
|
||||
<span>Maintenance</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
|
||||
<span>Fuel</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 bg-amber-500 rounded-full mr-2"></div>
|
||||
<span>Insurance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Efficiency Trend Chart -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Fuel Efficiency Trend (km per liter)</h3>
|
||||
<div class="h-80">
|
||||
<canvas ref="fuelEfficiencyChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Average: <span class="font-semibold">{{ averageFuelEfficiency.toFixed(1) }} km/L</span></p>
|
||||
<p class="text-green-600">↑ {{ ((averageFuelEfficiency - 12) / 12 * 100).toFixed(1) }}% improvement vs industry average</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost per Km and TCO Analysis -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<!-- Cost per Km Chart -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Cost per Kilometer Trend</h3>
|
||||
<div class="h-64">
|
||||
<canvas ref="costPerKmChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Average cost: <span class="font-semibold">€{{ averageCostPerKm.toFixed(2) }}/km</span></p>
|
||||
<p>Target: <span class="font-semibold">€0.38/km</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TCO Breakdown -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Total Cost of Ownership (TCO) Breakdown</h3>
|
||||
<div class="h-64">
|
||||
<canvas ref="tcoChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Annual TCO: <span class="font-semibold">€{{ formatNumber(businessMetrics.totalMonthlyCost * 12) }}</span></p>
|
||||
<p>Per vehicle: <span class="font-semibold">€{{ formatNumber(Math.round(businessMetrics.totalMonthlyCost * 12 / businessMetrics.fleetSize)) }}/year</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Monthly Performance Details</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Month</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Maintenance</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Fuel</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Insurance</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Cost/km</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Efficiency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="(month, index) in monthlyCosts" :key="month.month">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ month.month }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">€{{ month.maintenance }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">€{{ month.fuel }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">€{{ month.insurance }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">€{{ month.total }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">€{{ costPerKmTrends[index]?.cost.toFixed(2) || '0.00' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ fuelEfficiencyTrends[index]?.efficiency.toFixed(1) || '0.0' }} km/L</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Insights Panel -->
|
||||
<div class="mt-8 bg-gradient-to-r from-gray-800 to-gray-900 rounded-2xl p-6 text-white shadow-lg">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center mr-4">
|
||||
<span class="text-xl">💡</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">Business Insights</h3>
|
||||
<p class="text-gray-300">AI-powered recommendations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<div class="bg-white/10 p-4 rounded-xl">
|
||||
<h4 class="font-semibold mb-2">💰 Cost Optimization</h4>
|
||||
<p class="text-sm text-gray-200">Maintenance costs are {{ getCostComparison() }} than industry average. Consider preventive maintenance scheduling to reduce unexpected repairs.</p>
|
||||
</div>
|
||||
<div class="bg-white/10 p-4 rounded-xl">
|
||||
<h4 class="font-semibold mb-2">⛽ Fuel Efficiency</h4>
|
||||
<p class="text-sm text-gray-200">Your fleet is {{ getEfficiencyComparison() }} efficient than benchmark. Continue driver training programs for optimal performance.</p>
|
||||
</div>
|
||||
<div class="bg-white/10 p-4 rounded-xl">
|
||||
<h4 class="font-semibold mb-2">📅 Utilization Rate</h4>
|
||||
<p class="text-sm text-gray-200">{{ businessMetrics.utilizationRate }}% utilization is good. Consider dynamic routing to increase to 85% target.</p>
|
||||
</div>
|
||||
<div class="bg-white/10 p-4 rounded-xl">
|
||||
<h4 class="font-semibold mb-2">🔧 Downtime Management</h4>
|
||||
<p class="text-sm text-gray-200">{{ businessMetrics.downtimeHours }} hours/month downtime. Predictive maintenance could reduce this by 30%.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useAnalyticsStore } from '@/stores/analyticsStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const analyticsStore = useAnalyticsStore()
|
||||
const { monthlyCosts, fuelEfficiencyTrends, costPerKmTrends, businessMetrics, averageFuelEfficiency, averageCostPerKm } = storeToRefs(analyticsStore)
|
||||
|
||||
const monthlyCostsChart = ref(null)
|
||||
const fuelEfficiencyChart = ref(null)
|
||||
const costPerKmChart = ref(null)
|
||||
const tcoChart = ref(null)
|
||||
|
||||
let chartInstances = []
|
||||
|
||||
const formatNumber = (num) => {
|
||||
return new Intl.NumberFormat('en-US').format(num)
|
||||
}
|
||||
|
||||
const getCostComparison = () => {
|
||||
const avgMaintenance = monthlyCosts.value.reduce((sum, month) => sum + month.maintenance, 0) / monthlyCosts.value.length
|
||||
return avgMaintenance > 500 ? 'higher' : avgMaintenance < 400 ? 'lower' : 'similar'
|
||||
}
|
||||
|
||||
const getEfficiencyComparison = () => {
|
||||
return averageFuelEfficiency.value > 13 ? 'more' : averageFuelEfficiency.value < 12 ? 'less' : 'equally'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Monthly Costs Chart (Stacked Bar)
|
||||
if (monthlyCostsChart.value) {
|
||||
const ctx = monthlyCostsChart.value.getContext('2d')
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: monthlyCosts.value.map(m => m.month),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Maintenance',
|
||||
data: monthlyCosts.value.map(m => m.maintenance),
|
||||
backgroundColor: '#3b82f6',
|
||||
stack: 'Stack 0',
|
||||
},
|
||||
{
|
||||
label: 'Fuel',
|
||||
data: monthlyCosts.value.map(m => m.fuel),
|
||||
backgroundColor: '#10b981',
|
||||
stack: 'Stack 0',
|
||||
},
|
||||
{
|
||||
label: 'Insurance',
|
||||
data: monthlyCosts.value.map(m => m.insurance),
|
||||
backgroundColor: '#f59e0b',
|
||||
stack: 'Stack 0',
|
||||
},
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Cost (€)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
chartInstances.push(chart)
|
||||
}
|
||||
|
||||
// Fuel Efficiency Chart (Line)
|
||||
if (fuelEfficiencyChart.value) {
|
||||
const ctx = fuelEfficiencyChart.value.getContext('2d')
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: fuelEfficiencyTrends.value.map(m => m.month),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Fuel Efficiency (km/L)',
|
||||
data: fuelEfficiencyTrends.value.map(m => m.efficiency),
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'km per liter'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
chartInstances.push(chart)
|
||||
}
|
||||
|
||||
// Cost per Km Chart (Line)
|
||||
if (costPerKmChart.value) {
|
||||
const ctx = costPerKmChart.value.getContext('2d')
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: costPerKmTrends.value.map(m => m.month),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Cost per Kilometer (€)',
|
||||
data: costPerKmTrends.value.map(m => m.cost),
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: '€ per km'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
chartInstances.push(chart)
|
||||
}
|
||||
|
||||
// TCO Chart (Doughnut)
|
||||
if (tcoChart.value) {
|
||||
const ctx = tcoChart.value.getContext('2d')
|
||||
const totalMaintenance = monthlyCosts.value.reduce((sum, month) => sum + month.maintenance, 0)
|
||||
const totalFuel = monthlyCosts.value.reduce((sum, month) => sum + month.fuel, 0)
|
||||
const totalInsurance = monthlyCosts.value.reduce((sum, month) => sum + month.insurance, 0)
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Maintenance', 'Fuel', 'Insurance'],
|
||||
datasets: [
|
||||
{
|
||||
data: [totalMaintenance, totalFuel, totalInsurance],
|
||||
backgroundColor: ['#3b82f6', '#10b981', '#f59e0b'],
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff',
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
chartInstances.push(chart)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
chartInstances.forEach(chart => chart.destroy())
|
||||
chartInstances = []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.business-bi {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="fun-stats">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6">🎮 Fun Stats & Achievements</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Moon Trip Card -->
|
||||
<div class="bg-gradient-to-br from-blue-50 to-indigo-100 rounded-2xl p-6 shadow-lg border border-blue-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">🌙</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Moon Trip</h3>
|
||||
<p class="text-sm text-gray-600">Distance traveled</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-blue-700">{{ funFacts.moonTrips }}</div>
|
||||
<p class="text-gray-600 mt-2">trips to the Moon</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
You've driven <span class="font-semibold">{{ formatNumber(funFacts.totalKmDriven) }} km</span> - that's {{ funFacts.moonTrips }} trip{{ funFacts.moonTrips !== 1 ? 's' : '' }} to the Moon!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Earth Circuits Card -->
|
||||
<div class="bg-gradient-to-br from-green-50 to-emerald-100 rounded-2xl p-6 shadow-lg border border-green-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">🌍</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Earth Circuits</h3>
|
||||
<p class="text-sm text-gray-600">Around the world</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-green-700">{{ funFacts.earthCircuits }}</div>
|
||||
<p class="text-gray-600 mt-2">times around Earth</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
Equivalent to {{ funFacts.earthCircuits }} circuit{{ funFacts.earthCircuits !== 1 ? 's' : '' }} around the equator!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Trees Saved Card -->
|
||||
<div class="bg-gradient-to-br from-amber-50 to-orange-100 rounded-2xl p-6 shadow-lg border border-amber-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">🌳</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Trees Saved</h3>
|
||||
<p class="text-sm text-gray-600">Environmental impact</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-amber-700">{{ funFacts.totalTreesSaved }}</div>
|
||||
<p class="text-gray-600 mt-2">trees preserved</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
Your efficient driving saved {{ funFacts.totalTreesSaved }} tree{{ funFacts.totalTreesSaved !== 1 ? 's' : '' }} from CO₂ emissions!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CO₂ Saved Card -->
|
||||
<div class="bg-gradient-to-br from-purple-50 to-pink-100 rounded-2xl p-6 shadow-lg border border-purple-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">☁️</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">CO₂ Saved</h3>
|
||||
<p class="text-sm text-gray-600">Carbon footprint</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-purple-700">{{ funFacts.totalCo2Saved }}</div>
|
||||
<p class="text-gray-600 mt-2">tons of CO₂</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
That's like taking {{ Math.round(funFacts.totalCo2Saved * 1.8) }} cars off the road for a year!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Money Saved Card -->
|
||||
<div class="bg-gradient-to-br from-cyan-50 to-teal-100 rounded-2xl p-6 shadow-lg border border-cyan-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-cyan-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">💰</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Money Saved</h3>
|
||||
<p class="text-sm text-gray-600">Smart driving pays off</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-cyan-700">€{{ formatNumber(funFacts.totalMoneySaved) }}</div>
|
||||
<p class="text-gray-600 mt-2">total savings</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
Compared to average drivers, you saved €{{ formatNumber(funFacts.totalMoneySaved) }}!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Efficiency Card -->
|
||||
<div class="bg-gradient-to-br from-rose-50 to-red-100 rounded-2xl p-6 shadow-lg border border-rose-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-rose-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">⛽</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Fuel Efficiency</h3>
|
||||
<p class="text-sm text-gray-600">Your average</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-rose-700">{{ averageFuelEfficiency.toFixed(1) }}</div>
|
||||
<p class="text-gray-600 mt-2">km per liter</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
{{ getEfficiencyMessage(averageFuelEfficiency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fun Fact of the Day -->
|
||||
<div class="mt-8 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-6 text-white shadow-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center mr-4">
|
||||
<span class="text-xl">💡</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">Fun Fact of the Day</h3>
|
||||
<p class="text-indigo-100">Did you know?</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-lg">
|
||||
If every driver in your city achieved your fuel efficiency, we'd save enough CO₂ to fill {{ Math.round(funFacts.totalCo2Saved * 100) }} hot air balloons every year!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAnalyticsStore } from '@/stores/analyticsStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const analyticsStore = useAnalyticsStore()
|
||||
const { funFacts, averageFuelEfficiency } = storeToRefs(analyticsStore)
|
||||
|
||||
const formatNumber = (num) => {
|
||||
return new Intl.NumberFormat('en-US').format(num)
|
||||
}
|
||||
|
||||
const getEfficiencyMessage = (efficiency) => {
|
||||
if (efficiency > 15) return "Outstanding! You're among the top 5% most efficient drivers."
|
||||
if (efficiency > 12) return "Great job! You're more efficient than 80% of drivers."
|
||||
if (efficiency > 10) return "Good! You're above average in fuel efficiency."
|
||||
return 'Room for improvement. Check our tips to save more fuel.'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fun-stats {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="achievement-showcase">
|
||||
<!-- Mode indicator -->
|
||||
<div class="mode-indicator mb-8 p-4 rounded-xl bg-gradient-to-r from-slate-50 to-gray-100 border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center mr-4"
|
||||
:class="isPrivateGarage ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'"
|
||||
>
|
||||
{{ isPrivateGarage ? '🏆' : '🏅' }}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-lg">
|
||||
{{ isPrivateGarage ? 'Private Garage Trophy Showcase' : 'Corporate Fleet Badge Board' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ isPrivateGarage
|
||||
? 'Playful trophies for personal achievements'
|
||||
: 'Professional badges for fleet optimization'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-sm text-gray-500">
|
||||
Current mode:
|
||||
<span class="font-semibold" :class="isPrivateGarage ? 'text-amber-700' : 'text-emerald-700'">
|
||||
{{ isPrivateGarage ? 'Private Garage' : 'Corporate Fleet' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleMode"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
|
||||
:class="isPrivateGarage
|
||||
? 'border-amber-300 text-amber-700 bg-amber-50 hover:bg-amber-100'
|
||||
: 'border-emerald-300 text-emerald-700 bg-emerald-50 hover:bg-emerald-100'"
|
||||
>
|
||||
Switch to {{ isPrivateGarage ? 'Corporate' : 'Private' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic component rendering -->
|
||||
<div class="component-container">
|
||||
<TrophyCabinet v-if="isPrivateGarage" />
|
||||
<BadgeBoard v-else />
|
||||
</div>
|
||||
|
||||
<!-- Gamification stats -->
|
||||
<div class="mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="p-5 rounded-xl bg-white border border-gray-200 shadow-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xl mr-4">
|
||||
📈
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ earnedCount }}</div>
|
||||
<div class="text-sm text-gray-600">Achievements Earned</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 rounded-xl bg-white border border-gray-200 shadow-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 text-xl mr-4">
|
||||
🎯
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ progressPercentage }}%</div>
|
||||
<div class="text-sm text-gray-600">Overall Progress</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 rounded-xl bg-white border border-gray-200 shadow-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center text-green-600 text-xl mr-4">
|
||||
⭐
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ nextAchievement }}</div>
|
||||
<div class="text-sm text-gray-600">Next Achievement</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help text -->
|
||||
<div class="mt-8 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div class="flex items-start">
|
||||
<div class="text-gray-500 mr-3">💡</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<span class="font-semibold">How to earn more:</span>
|
||||
{{ isPrivateGarage
|
||||
? 'Add vehicles, log expenses, complete daily quizzes, and find services to unlock trophies.'
|
||||
: 'Optimize fleet efficiency, reduce costs, manage multiple vehicles, and maintain service records to earn badges.'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useGamificationStore } from '@/stores/gamificationStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import TrophyCabinet from './TrophyCabinet.vue'
|
||||
import BadgeBoard from './BadgeBoard.vue'
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const gamificationStore = useGamificationStore()
|
||||
|
||||
const { isPrivateGarage, isCorporateFleet, toggleMode } = appModeStore
|
||||
const { earnedCount, progressPercentage, lockedAchievements } = storeToRefs(gamificationStore)
|
||||
|
||||
const nextAchievement = computed(() => {
|
||||
if (lockedAchievements.value.length > 0) {
|
||||
return lockedAchievements.value[0].title
|
||||
}
|
||||
return 'All earned!'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.achievement-showcase {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="badge-board">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-slate-800 mb-2">🏅 Efficiency Badges</h2>
|
||||
<p class="text-gray-600">Professional recognition for fleet optimization and cost management.</p>
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-emerald-500 mr-2"></div>
|
||||
<span class="text-sm text-gray-700">Earned: {{ earnedCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-gray-300 mr-2"></div>
|
||||
<span class="text-sm text-gray-700">Available: {{ totalAchievements - earnedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-500">Fleet Score</div>
|
||||
<div class="text-2xl font-bold text-slate-800">{{ fleetScore }}/100</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="achievement in achievements"
|
||||
:key="achievement.id"
|
||||
class="badge-card p-6 rounded-xl border transition-all duration-300"
|
||||
:class="[
|
||||
achievement.isEarned
|
||||
? 'border-emerald-200 bg-white shadow-md hover:shadow-lg'
|
||||
: 'border-gray-200 bg-gray-50'
|
||||
]"
|
||||
>
|
||||
<!-- Badge header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl"
|
||||
:class="achievement.isEarned ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-400'"
|
||||
>
|
||||
{{ achievement.icon }}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="font-bold text-lg" :class="achievement.isEarned ? 'text-slate-900' : 'text-gray-500'">
|
||||
{{ achievement.title }}
|
||||
</h3>
|
||||
<div class="text-xs font-medium px-2 py-1 rounded-full inline-block mt-1"
|
||||
:class="achievement.isEarned ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-500'">
|
||||
{{ achievement.category.toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div v-if="achievement.isEarned" class="text-emerald-600">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-gray-600 mb-5" :class="{ 'opacity-70': !achievement.isEarned }">
|
||||
{{ achievement.description }}
|
||||
</p>
|
||||
|
||||
<!-- Progress bar for unearned badges -->
|
||||
<div v-if="!achievement.isEarned" class="mt-4">
|
||||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Progress</span>
|
||||
<span>0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-gray-400 h-2 rounded-full" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Earned details -->
|
||||
<div v-if="achievement.isEarned" class="mt-4 pt-4 border-t border-gray-100">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span class="font-medium">Awarded:</span> {{ achievement.earnedDate }}
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-emerald-700">
|
||||
+{{ badgePoints(achievement.category) }} pts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action button -->
|
||||
<div class="mt-6">
|
||||
<button
|
||||
v-if="!achievement.isEarned"
|
||||
class="w-full py-2 text-sm font-medium rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
disabled
|
||||
>
|
||||
Not Yet Achieved
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="w-full py-2 text-sm font-medium rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="mt-10 p-6 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<h3 class="font-bold text-lg text-slate-800 mb-4">Fleet Performance Summary</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-slate-800">{{ earnedCount }}</div>
|
||||
<div class="text-sm text-gray-600">Badges Earned</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-slate-800">{{ fleetScore }}</div>
|
||||
<div class="text-sm text-gray-600">Fleet Score</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-slate-800">{{ efficiencyBadgesCount }}</div>
|
||||
<div class="text-sm text-gray-600">Efficiency Badges</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-slate-800">{{ corporateBadgesCount }}</div>
|
||||
<div class="text-sm text-gray-600">Corporate Badges</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useGamificationStore } from '@/stores/gamificationStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const gamificationStore = useGamificationStore()
|
||||
const {
|
||||
achievements,
|
||||
earnedCount,
|
||||
totalAchievements
|
||||
} = storeToRefs(gamificationStore)
|
||||
|
||||
// Computed
|
||||
const fleetScore = computed(() => {
|
||||
const base = earnedCount.value * 12
|
||||
return Math.min(base, 100)
|
||||
})
|
||||
|
||||
const efficiencyBadgesCount = computed(() => {
|
||||
return achievements.value.filter(a =>
|
||||
a.category === 'efficiency' && a.isEarned
|
||||
).length
|
||||
})
|
||||
|
||||
const corporateBadgesCount = computed(() => {
|
||||
return achievements.value.filter(a =>
|
||||
a.category === 'corporate' && a.isEarned
|
||||
).length
|
||||
})
|
||||
|
||||
const badgePoints = (category) => {
|
||||
const points = {
|
||||
efficiency: 25,
|
||||
corporate: 30,
|
||||
finance: 20,
|
||||
service: 15,
|
||||
onboarding: 10,
|
||||
knowledge: 15,
|
||||
consistency: 10,
|
||||
social: 5
|
||||
}
|
||||
return points[category] || 10
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.badge-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.badge-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="trophy-cabinet">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-amber-800 mb-2">🏆 Trophy Cabinet</h2>
|
||||
<p class="text-gray-600">Your earned achievements shine here! Collect more to fill your shelf.</p>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
class="bg-gradient-to-r from-amber-400 to-amber-600 h-3 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${progressPercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="ml-4 text-sm font-semibold text-amber-700">{{ earnedCount }}/{{ totalAchievements }} ({{ progressPercentage }}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div
|
||||
v-for="achievement in achievements"
|
||||
:key="achievement.id"
|
||||
class="relative group"
|
||||
>
|
||||
<div
|
||||
class="trophy-card p-5 rounded-2xl border-2 transition-all duration-300 transform"
|
||||
:class="[
|
||||
achievement.isEarned
|
||||
? 'border-amber-300 bg-gradient-to-br from-amber-50 to-amber-100 shadow-lg hover:shadow-2xl hover:scale-105'
|
||||
: 'border-gray-300 bg-gray-100 opacity-60 grayscale'
|
||||
]"
|
||||
>
|
||||
<!-- Trophy Icon -->
|
||||
<div class="text-5xl mb-4 text-center">
|
||||
{{ achievement.icon }}
|
||||
</div>
|
||||
|
||||
<!-- Lock overlay for unearned -->
|
||||
<div
|
||||
v-if="!achievement.isEarned"
|
||||
class="absolute inset-0 bg-gray-800 bg-opacity-70 rounded-2xl flex items-center justify-center"
|
||||
>
|
||||
<div class="text-white text-center">
|
||||
<div class="text-3xl mb-2">🔒</div>
|
||||
<div class="text-sm font-semibold">Locked</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<h3 class="text-lg font-bold mb-2" :class="achievement.isEarned ? 'text-gray-900' : 'text-gray-500'">
|
||||
{{ achievement.title }}
|
||||
</h3>
|
||||
<p class="text-sm mb-3" :class="achievement.isEarned ? 'text-gray-700' : 'text-gray-400'">
|
||||
{{ achievement.description }}
|
||||
</p>
|
||||
|
||||
<!-- Category badge -->
|
||||
<div class="inline-block px-3 py-1 text-xs rounded-full"
|
||||
:class="achievement.isEarned ? 'bg-amber-200 text-amber-800' : 'bg-gray-300 text-gray-500'">
|
||||
{{ achievement.category }}
|
||||
</div>
|
||||
|
||||
<!-- Earned date -->
|
||||
<div v-if="achievement.isEarned" class="mt-4 pt-3 border-t border-amber-200">
|
||||
<div class="text-xs text-amber-600 font-medium">
|
||||
🎉 Earned on {{ achievement.earnedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glow effect for earned trophies on hover -->
|
||||
<div
|
||||
v-if="achievement.isEarned"
|
||||
class="absolute -inset-1 bg-gradient-to-r from-blue-400 to-blue-600 rounded-2xl blur opacity-0 group-hover:opacity-30 transition-opacity duration-300 -z-10"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state message -->
|
||||
<div v-if="earnedCount === 0" class="text-center py-12">
|
||||
<div class="text-6xl mb-4">📭</div>
|
||||
<h3 class="text-xl font-semibold text-gray-700 mb-2">No trophies yet!</h3>
|
||||
<p class="text-gray-500">Start using the app to earn your first achievements.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useGamificationStore } from '@/stores/gamificationStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const gamificationStore = useGamificationStore()
|
||||
const {
|
||||
achievements,
|
||||
earnedCount,
|
||||
totalAchievements,
|
||||
progressPercentage
|
||||
} = storeToRefs(gamificationStore)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trophy-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
vehicles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// Enhanced status colors for corporate look
|
||||
const statusColors = {
|
||||
'OK': 'bg-emerald-50 text-emerald-700 border border-emerald-200',
|
||||
'Service Due': 'bg-blue-50 text-blue-900 border border-blue-200 animate-pulse',
|
||||
'Warning': 'bg-rose-50 text-rose-700 border border-rose-200',
|
||||
'draft': 'bg-yellow-50 text-yellow-800 border border-yellow-300',
|
||||
'verified': 'bg-green-50 text-green-800 border border-green-300',
|
||||
'active': 'bg-blue-50 text-blue-800 border border-blue-300',
|
||||
'pending': 'bg-gray-50 text-gray-800 border border-gray-300',
|
||||
'incomplete': 'bg-orange-50 text-orange-800 border border-orange-300'
|
||||
}
|
||||
|
||||
const sortedVehicles = computed(() => {
|
||||
return [...props.vehicles].sort((a, b) => b.monthlyExpense - a.monthlyExpense)
|
||||
})
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatMileage = (mileage) => {
|
||||
return new Intl.NumberFormat('en-US').format(mileage)
|
||||
}
|
||||
|
||||
// Country flag mapping
|
||||
const getCountryFlag = (make) => {
|
||||
const makeLower = make.toLowerCase()
|
||||
if (makeLower.includes('bmw') || makeLower.includes('mercedes') || makeLower.includes('audi') || makeLower.includes('volkswagen') || makeLower.includes('porsche')) {
|
||||
return 'https://flagcdn.com/w40/de.png'
|
||||
} else if (makeLower.includes('tesla') || makeLower.includes('ford') || makeLower.includes('chevrolet') || makeLower.includes('dodge')) {
|
||||
return 'https://flagcdn.com/w40/us.png'
|
||||
} else if (makeLower.includes('toyota') || makeLower.includes('honda') || makeLower.includes('nissan') || makeLower.includes('mazda')) {
|
||||
return 'https://flagcdn.com/w40/jp.png'
|
||||
} else if (makeLower.includes('ferrari') || makeLower.includes('lamborghini') || makeLower.includes('fiat') || makeLower.includes('alfa romeo')) {
|
||||
return 'https://flagcdn.com/w40/it.png'
|
||||
} else if (makeLower.includes('volvo') || makeLower.includes('saab')) {
|
||||
return 'https://flagcdn.com/w40/se.png'
|
||||
} else if (makeLower.includes('renault') || makeLower.includes('peugeot') || makeLower.includes('citroen')) {
|
||||
return 'https://flagcdn.com/w40/fr.png'
|
||||
} else if (makeLower.includes('skoda') || makeLower.includes('seat')) {
|
||||
return 'https://flagcdn.com/w40/cz.png'
|
||||
} else {
|
||||
return 'https://flagcdn.com/w40/eu.png'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white/80 backdrop-blur-sm rounded-2xl shadow-2xl border border-gray-300/50 overflow-hidden">
|
||||
<!-- Corporate Glass Header -->
|
||||
<div class="px-8 py-5 border-b border-gray-300/30 bg-gradient-to-r from-slate-900/90 to-slate-800/90 backdrop-blur-md">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white tracking-tight">Corporate Fleet Management</h2>
|
||||
<p class="text-sm text-slate-300 mt-1">Enterprise-grade vehicle oversight with real-time analytics</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-3xl font-bold text-white">{{ formatCurrency(vehicles.reduce((sum, v) => sum + v.monthlyExpense, 0)) }}</div>
|
||||
<div class="text-sm text-slate-300">Total monthly fleet cost • {{ vehicles.length }} assets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Container -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-300/30">
|
||||
<thead class="bg-gradient-to-r from-slate-100 to-slate-200/80">
|
||||
<tr>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">🚗</span> Vehicle
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">🏷️</span> License Plate
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">📅</span> Year
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">📊</span> Mileage
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">⛽</span> Fuel Type
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">🔧</span> Data Status
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">💰</span> Monthly Cost
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">⚡</span> Actions
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-300/20">
|
||||
<tr
|
||||
v-for="(vehicle, index) in sortedVehicles"
|
||||
:key="vehicle.id"
|
||||
:class="[
|
||||
'transition-all duration-200 hover:bg-slate-100/80',
|
||||
index % 2 === 0 ? 'bg-white' : 'bg-slate-50/70'
|
||||
]"
|
||||
>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="h-12 w-12 flex-shrink-0 bg-gradient-to-br from-slate-200 to-slate-300 rounded-xl overflow-hidden mr-4 shadow-sm border border-slate-300/50">
|
||||
<img
|
||||
:src="vehicle.imageUrl"
|
||||
:alt="`${vehicle.make} ${vehicle.model}`"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-slate-900 text-lg">{{ vehicle.make }} {{ vehicle.model }}</div>
|
||||
<div class="text-sm text-slate-600 mt-1">ID: {{ vehicle.id }} • Asset</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-3">
|
||||
<img
|
||||
:src="getCountryFlag(vehicle.make)"
|
||||
:alt="`${vehicle.make} origin flag`"
|
||||
class="w-6 h-4 rounded-sm shadow-md border border-slate-300"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-mono font-bold text-slate-900 text-lg tracking-wider">{{ vehicle.licensePlate }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">Registered</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-slate-900">{{ vehicle.year }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">Model Year</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-slate-900">{{ formatMileage(vehicle.mileage) }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">km</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<span :class="[
|
||||
'px-4 py-2 rounded-full text-sm font-semibold shadow-sm',
|
||||
vehicle.fuelType === 'Electric' ? 'bg-emerald-100 text-emerald-800 border border-emerald-300' :
|
||||
vehicle.fuelType === 'Diesel' ? 'bg-blue-100 text-blue-800 border border-blue-300' :
|
||||
'bg-amber-100 text-amber-800 border border-amber-300'
|
||||
]">
|
||||
{{ vehicle.fuelType }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="space-y-2">
|
||||
<!-- Data Status Badge -->
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
:class="['px-3 py-1.5 rounded-full text-xs font-semibold flex items-center', statusColors[vehicle.data_status || vehicle.status] || 'bg-slate-100 text-slate-800 border border-slate-300']"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-2"
|
||||
:class="{
|
||||
'bg-emerald-500': (vehicle.data_status || vehicle.status) === 'OK' || (vehicle.data_status || vehicle.status) === 'verified',
|
||||
'bg-blue-500': (vehicle.data_status || vehicle.status) === 'Service Due' || (vehicle.data_status || vehicle.status) === 'active',
|
||||
'bg-rose-500': (vehicle.data_status || vehicle.status) === 'Warning',
|
||||
'bg-yellow-500': (vehicle.data_status || vehicle.status) === 'draft',
|
||||
'bg-gray-500': (vehicle.data_status || vehicle.status) === 'pending',
|
||||
'bg-orange-500': (vehicle.data_status || vehicle.status) === 'incomplete'
|
||||
}"
|
||||
></span>
|
||||
{{ vehicle.data_status || vehicle.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Profile Completion Mini Progress Bar -->
|
||||
<div v-if="vehicle.profile_completion_percentage !== undefined" class="flex items-center space-x-2">
|
||||
<div class="w-16 bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
'bg-yellow-500': (vehicle.profile_completion_percentage || 0) < 50,
|
||||
'bg-blue-500': (vehicle.profile_completion_percentage || 0) >= 50 && (vehicle.profile_completion_percentage || 0) < 80,
|
||||
'bg-green-500': (vehicle.profile_completion_percentage || 0) >= 80
|
||||
}"
|
||||
:style="{ width: (vehicle.profile_completion_percentage || 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 font-medium">{{ vehicle.profile_completion_percentage || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-slate-900">{{ formatCurrency(vehicle.monthlyExpense) }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">per month</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="flex space-x-2">
|
||||
<button class="px-4 py-2.5 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg">
|
||||
View Details
|
||||
</button>
|
||||
<button class="px-3 py-2.5 border border-slate-300 hover:bg-slate-100 rounded-xl text-slate-700 transition-all duration-200 active:scale-95 shadow-sm hover:shadow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Corporate Footer -->
|
||||
<div class="px-8 py-5 border-t border-gray-300/30 bg-gradient-to-r from-slate-100 to-slate-200/80">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-slate-700">
|
||||
<span class="font-semibold">Showing {{ vehicles.length }} of {{ vehicles.length }} corporate assets</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>Last updated: {{ new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }}</span>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button class="px-5 py-2.5 border border-slate-300 hover:bg-white text-slate-700 font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-sm hover:shadow flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Export CSV
|
||||
</button>
|
||||
<button class="px-5 py-2.5 bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 text-white font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Vehicle
|
||||
</button>
|
||||
<button class="px-5 py-2.5 bg-gradient-to-r from-slate-700 to-slate-800 hover:from-slate-800 hover:to-slate-900 text-white font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Analytics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom table styles */
|
||||
table {
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* Smooth row transitions */
|
||||
tr {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Custom scrollbar for table */
|
||||
.overflow-x-auto::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.overflow-x-auto::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-x-auto::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
|
||||
defineProps({
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const themeClasses = themeStore.themeClasses
|
||||
|
||||
const statusColors = {
|
||||
'OK': 'bg-green-100 text-green-800',
|
||||
'Service Due': 'bg-blue-100 text-blue-900',
|
||||
'Warning': 'bg-orange-100 text-orange-800',
|
||||
'draft': 'bg-yellow-100 text-yellow-800',
|
||||
'verified': 'bg-green-100 text-green-800',
|
||||
'active': 'bg-blue-100 text-blue-800',
|
||||
'pending': 'bg-gray-100 text-gray-800',
|
||||
'incomplete': 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
|
||||
const brandLogoUrl = (make) => {
|
||||
const cleanMake = (make || '').toLowerCase().replace(/\s+/g, '')
|
||||
// Use simpleicons CDN
|
||||
return `https://cdn.simpleicons.org/${cleanMake}`
|
||||
}
|
||||
|
||||
// Country flag mapping
|
||||
const getCountryFlag = (make) => {
|
||||
const makeLower = (make || '').toLowerCase()
|
||||
if (makeLower.includes('bmw') || makeLower.includes('mercedes') || makeLower.includes('audi') || makeLower.includes('volkswagen') || makeLower.includes('porsche')) {
|
||||
return 'https://flagcdn.com/w40/de.png'
|
||||
} else if (makeLower.includes('tesla') || makeLower.includes('ford') || makeLower.includes('chevrolet') || makeLower.includes('dodge')) {
|
||||
return 'https://flagcdn.com/w40/us.png'
|
||||
} else if (makeLower.includes('toyota') || makeLower.includes('honda') || makeLower.includes('nissan') || makeLower.includes('mazda')) {
|
||||
return 'https://flagcdn.com/w40/jp.png'
|
||||
} else if (makeLower.includes('ferrari') || makeLower.includes('lamborghini') || makeLower.includes('fiat') || makeLower.includes('alfa romeo')) {
|
||||
return 'https://flagcdn.com/w40/it.png'
|
||||
} else if (makeLower.includes('volvo') || makeLower.includes('saab')) {
|
||||
return 'https://flagcdn.com/w40/se.png'
|
||||
} else if (makeLower.includes('renault') || makeLower.includes('peugeot') || makeLower.includes('citroen')) {
|
||||
return 'https://flagcdn.com/w40/fr.png'
|
||||
} else if (makeLower.includes('skoda') || makeLower.includes('seat')) {
|
||||
return 'https://flagcdn.com/w40/cz.png'
|
||||
} else {
|
||||
return 'https://flagcdn.com/w40/eu.png'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['rounded-2xl shadow-lg overflow-hidden hover:shadow-xl transition-all duration-500 border', themeClasses.card]">
|
||||
<!-- Vehicle Image -->
|
||||
<div class="h-48 bg-gray-200 relative overflow-hidden">
|
||||
<img
|
||||
:src="vehicle.imageUrl"
|
||||
:alt="`${vehicle.make} ${vehicle.model}`"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<!-- Brand Logo -->
|
||||
<div class="absolute top-3 left-3 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-md">
|
||||
<img
|
||||
:src="brandLogoUrl(vehicle.make)"
|
||||
:alt="vehicle.make"
|
||||
class="w-8 h-8"
|
||||
@error="(e) => e.target.style.display = 'none'"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute top-3 right-3">
|
||||
<span
|
||||
:class="['px-3 py-1 rounded-full text-xs font-semibold', statusColors[vehicle.data_status || vehicle.status] || 'bg-gray-100 text-gray-800']"
|
||||
>
|
||||
{{ vehicle.data_status || vehicle.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Details -->
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ vehicle.make }} {{ vehicle.model }}</h3>
|
||||
<p class="text-gray-600">{{ vehicle.year }} • {{ vehicle.fuelType }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-blue-700">€{{ vehicle.monthlyExpense }}</div>
|
||||
<div class="text-sm text-gray-500">/month</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- License Plate with Country Flag -->
|
||||
<div class="mb-4">
|
||||
<div class="inline-flex items-center bg-gray-100 px-4 py-2 rounded-lg space-x-3">
|
||||
<img
|
||||
:src="getCountryFlag(vehicle.make)"
|
||||
:alt="`${vehicle.make} origin flag`"
|
||||
class="w-6 h-4 rounded-sm shadow-sm"
|
||||
/>
|
||||
<span class="text-gray-700 font-mono font-bold text-lg">{{ vehicle.licensePlate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Completion Progress Bar (shown for all vehicles with <100% completion) -->
|
||||
<div v-if="(vehicle.profile_completion_percentage || 0) < 100" class="mb-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
v-if="vehicle.data_status === 'draft'"
|
||||
class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
DRAFT
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-xs text-gray-600 mb-1">Profile: {{ vehicle.profile_completion_percentage || 0 }}% Complete</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-500"
|
||||
:class="{
|
||||
'bg-yellow-500': (vehicle.profile_completion_percentage || 0) < 50,
|
||||
'bg-blue-500': (vehicle.profile_completion_percentage || 0) >= 50 && (vehicle.profile_completion_percentage || 0) < 80,
|
||||
'bg-green-500': (vehicle.profile_completion_percentage || 0) >= 80
|
||||
}"
|
||||
:style="{ width: (vehicle.profile_completion_percentage || 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="vehicle.data_status === 'draft'" class="text-xs text-gray-500 mt-1">Edit vehicle to provide VIN or Catalog ID</p>
|
||||
<p v-else class="text-xs text-gray-500 mt-1">Complete your vehicle profile for better service recommendations</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">{{ ((vehicle.mileage || 0) / 1000).toFixed(1) }}k</div>
|
||||
<div class="text-sm text-gray-600">km</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">{{ vehicle.fuelType?.charAt(0) || 'U' }}</div>
|
||||
<div class="text-sm text-gray-600">Fuel</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex space-x-3">
|
||||
<button class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors duration-200">
|
||||
View Details
|
||||
</button>
|
||||
<button class="px-4 py-3 border border-gray-300 hover:bg-gray-50 rounded-lg transition-colors duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-600" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom scrollbar for future use */
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useGarageStore } from '@/stores/garageStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import VehicleCard from './VehicleCard.vue'
|
||||
import FleetTable from './FleetTable.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const garageStore = useGarageStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Animation state
|
||||
const isMounted = ref(false)
|
||||
const organizations = ref([])
|
||||
const activeOrganizationName = ref('')
|
||||
const loadingOrganizations = ref(false)
|
||||
|
||||
// Fetch vehicles on component mount (simulated)
|
||||
onMounted(() => {
|
||||
garageStore.fetchVehicles()
|
||||
// Trigger animation after mount
|
||||
setTimeout(() => {
|
||||
isMounted.value = true
|
||||
}, 100)
|
||||
|
||||
// Fetch organizations if in fleet mode
|
||||
if (appModeStore.isCorporateFleet) {
|
||||
fetchOrganizations()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for mode changes
|
||||
watch(() => appModeStore.mode, (newMode) => {
|
||||
if (newMode === 'fleet') {
|
||||
fetchOrganizations()
|
||||
} else {
|
||||
activeOrganizationName.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch user's organizations
|
||||
const fetchOrganizations = async () => {
|
||||
if (!authStore.token) return
|
||||
|
||||
try {
|
||||
loadingOrganizations.value = true
|
||||
const response = await api.get('/organizations/my')
|
||||
organizations.value = response.data || []
|
||||
|
||||
// Find active organization name
|
||||
const activeOrgId = authStore.activeOrgId || authStore.userProfile?.active_organization_id
|
||||
if (activeOrgId && organizations.value.length > 0) {
|
||||
// Try to find organization by ID
|
||||
// Note: The current endpoint returns limited data, we may need to enhance it
|
||||
const org = organizations.value.find(o => o.organization_id === parseInt(activeOrgId))
|
||||
if (org) {
|
||||
activeOrganizationName.value = org.display_name || org.name || org.full_name || `Organization #${activeOrgId}`
|
||||
} else {
|
||||
activeOrganizationName.value = 'Corporate Fleet'
|
||||
}
|
||||
} else {
|
||||
activeOrganizationName.value = 'Corporate Fleet'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch organizations:', error)
|
||||
activeOrganizationName.value = 'Corporate Fleet'
|
||||
} finally {
|
||||
loadingOrganizations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stats = computed(() => ({
|
||||
totalVehicles: garageStore.totalVehicles,
|
||||
totalMonthlyExpense: garageStore.totalMonthlyExpense,
|
||||
vehiclesNeedingService: garageStore.vehiclesNeedingService
|
||||
}))
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
// Computed for header title
|
||||
const headerTitle = computed(() => {
|
||||
if (appModeStore.isPrivateGarage) {
|
||||
return 'Saját Garázs'
|
||||
} else {
|
||||
if (activeOrganizationName.value && activeOrganizationName.value !== 'Corporate Fleet') {
|
||||
return `Céges Flotta: ${activeOrganizationName.value}`
|
||||
}
|
||||
return 'Céges Flotta'
|
||||
}
|
||||
})
|
||||
|
||||
// Computed for header description
|
||||
const headerDescription = computed(() => {
|
||||
if (appModeStore.isPrivateGarage) {
|
||||
return 'Your personal vehicle collection and maintenance tracker'
|
||||
} else {
|
||||
if (activeOrganizationName.value && activeOrganizationName.value !== 'Corporate Fleet') {
|
||||
return `Company-wide vehicle management and cost analytics for ${activeOrganizationName.value}`
|
||||
}
|
||||
return 'Company-wide vehicle management and cost analytics'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Header with Stats -->
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-2xl p-6 border border-blue-100">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
{{ headerTitle }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="appModeStore.isPrivateGarage"
|
||||
class="px-3 py-1 bg-gradient-to-r from-green-500 to-emerald-600 text-white text-xs font-semibold rounded-full shadow-sm"
|
||||
>
|
||||
B2C
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-3 py-1 bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-xs font-semibold rounded-full shadow-sm"
|
||||
>
|
||||
B2B
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-gray-600 mt-2">
|
||||
{{ headerDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button
|
||||
@click="appModeStore.toggleMode"
|
||||
class="px-4 py-2 bg-white border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-50 transition-all duration-300 flex items-center active:scale-95"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Switch to {{ appModeStore.isPrivateGarage ? 'Corporate' : 'Private' }} View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="(stat, index) in [
|
||||
{ label: 'Total Vehicles', value: stats.totalVehicles, icon: 'check', color: 'blue' },
|
||||
{ label: 'Monthly Cost', value: formatCurrency(stats.totalMonthlyExpense), icon: 'currency', color: 'green' },
|
||||
{ label: 'Need Service', value: stats.vehiclesNeedingService, icon: 'warning', color: 'orange' }
|
||||
]"
|
||||
:key="stat.label"
|
||||
class="bg-white rounded-xl p-5 shadow-sm border border-gray-200 transition-all duration-500 hover:shadow-md hover:-translate-y-1"
|
||||
:style="{
|
||||
opacity: isMounted ? 1 : 0,
|
||||
transform: isMounted ? 'translateY(0)' : 'translateY(20px)',
|
||||
transitionDelay: `${index * 100}ms`
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div :class="[`p-3 bg-${stat.color}-100 rounded-lg mr-4`]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" :class="[`h-6 w-6 text-${stat.color}-600`]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path v-if="stat.icon === 'check'" 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" />
|
||||
<path v-if="stat.icon === 'currency'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path v-if="stat.icon === 'warning'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.998-.833-2.732 0L4.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ stat.value }}</div>
|
||||
<div class="text-gray-600">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual-UI Content -->
|
||||
<div v-if="appModeStore.isPrivateGarage">
|
||||
<!-- Private Garage: Card Grid with TransitionGroup -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">My Vehicles</h2>
|
||||
<div class="text-gray-600">
|
||||
{{ garageStore.vehicles.length }} vehicles in your garage
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
name="stagger-card"
|
||||
tag="div"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 gap-6"
|
||||
>
|
||||
<VehicleCard
|
||||
v-for="(vehicle, index) in garageStore.vehicles"
|
||||
:key="vehicle.id"
|
||||
:vehicle="vehicle"
|
||||
:style="{
|
||||
opacity: isMounted ? 1 : 0,
|
||||
transform: isMounted ? 'translateY(0) scale(1)' : 'translateY(30px) scale(0.95)',
|
||||
transitionDelay: `${index * 150}ms`
|
||||
}"
|
||||
class="transition-all duration-700 ease-out"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Corporate Fleet: Table View -->
|
||||
<FleetTable :vehicles="garageStore.vehicles" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State (if no vehicles) -->
|
||||
<div v-if="garageStore.vehicles.length === 0" class="text-center py-12">
|
||||
<div class="text-6xl text-gray-300 mb-4">🚗</div>
|
||||
<h3 class="text-xl font-bold text-gray-500 mb-2">No vehicles yet</h3>
|
||||
<p class="text-gray-600 mb-6">Add your first vehicle to get started</p>
|
||||
<button
|
||||
@click="router.push('/vehicles/add')"
|
||||
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-300 hover:scale-105 active:scale-95"
|
||||
>
|
||||
Add First Vehicle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Staggered card animations */
|
||||
.stagger-card-move {
|
||||
transition: transform 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.stagger-card-enter-active,
|
||||
.stagger-card-leave-active {
|
||||
transition: all 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.stagger-card-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
.stagger-card-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px) scale(0.95);
|
||||
}
|
||||
|
||||
.stagger-card-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Smooth hover effects */
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
</style>
|
||||
33
old_maps/frontend_old/Archive/src/main.js
Executable file
33
old_maps/frontend_old/Archive/src/main.js
Executable file
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { markRaw } from 'vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Global error handler
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
console.error('Global Vue error caught:', err)
|
||||
console.error('Error info:', info)
|
||||
// Optionally show a user-friendly error message
|
||||
// You could integrate with a notification store here
|
||||
}
|
||||
|
||||
// Global promise rejection handler (for unhandled async errors)
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason)
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
// Inject router into all Pinia stores
|
||||
pinia.use(({ store }) => {
|
||||
store.router = markRaw(router)
|
||||
})
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
151
old_maps/frontend_old/Archive/src/router/index.js
Executable file
151
old_maps/frontend_old/Archive/src/router/index.js
Executable file
@@ -0,0 +1,151 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
// Nézetek importálása
|
||||
import Dashboard from '../views/Dashboard.vue';
|
||||
import Expenses from '../views/Expenses.vue';
|
||||
import AddExpense from '../views/AddExpense.vue';
|
||||
import Login from '../views/Login.vue';
|
||||
import Register from '../views/Register.vue';
|
||||
import ForgotPassword from '../views/ForgotPassword.vue';
|
||||
import ResetPassword from '../views/ResetPassword.vue';
|
||||
import AddVehicle from '../views/AddVehicle.vue';
|
||||
import AdminStats from '../views/admin/AdminStats.vue';
|
||||
import ProfileSelect from '../views/ProfileSelect.vue';
|
||||
import Debug from '../views/Debug.vue';
|
||||
|
||||
const routes = [
|
||||
// Védett útvonalak
|
||||
{ path: '/', name: 'Dashboard', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/expenses', name: 'Expenses', component: Expenses, meta: { requiresAuth: true } },
|
||||
{ path: '/expenses/add', name: 'AddExpense', component: AddExpense, meta: { requiresAuth: true } },
|
||||
{ path: '/vehicles/add', name: 'AddVehicle', component: AddVehicle, meta: { requiresAuth: true } },
|
||||
|
||||
// Profile selection (public but requires auth)
|
||||
{ path: '/profile-select', name: 'ProfileSelect', component: ProfileSelect, meta: { requiresAuth: true } },
|
||||
|
||||
// ADMIN útvonal
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
component: AdminStats,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
|
||||
// Nyilvános útvonalak
|
||||
{ path: '/login', name: 'Login', component: Login },
|
||||
{ path: '/register', name: 'Register', component: Register },
|
||||
{ path: '/forgot-password', name: 'ForgotPassword', component: ForgotPassword },
|
||||
{ path: '/reset-password', name: 'ResetPassword', component: ResetPassword },
|
||||
|
||||
// DEBUG útvonal (nyilvános, nincs auth check)
|
||||
{ path: '/debug', name: 'Debug', component: Debug },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
// Helper function to check if UI mode is selected
|
||||
function hasUIModeSelected() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const saved = localStorage.getItem('ui_mode');
|
||||
// Accept both UI values (private_garage, corporate_fleet) and backend values (personal, fleet)
|
||||
return saved === 'private_garage' || saved === 'corporate_fleet' || saved === 'personal' || saved === 'fleet';
|
||||
}
|
||||
|
||||
// A "SOROMPÓ" (Auth Guard) LOGIKA
|
||||
router.beforeEach((to, from, next) => {
|
||||
console.group('🚀 Router Navigation Guard - Flight Recorder');
|
||||
console.log(`📊 Navigation: ${from.path} → ${to.path}`);
|
||||
console.log(`📍 To Route:`, to);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const isAdmin = localStorage.getItem('is_admin') === 'true';
|
||||
const uiMode = localStorage.getItem('ui_mode');
|
||||
|
||||
// Import stores to get real-time state
|
||||
let isAuthenticated = false;
|
||||
let appMode = null;
|
||||
|
||||
try {
|
||||
// Try to get auth store state if available
|
||||
const authStore = window.__pinia?.state.value?.auth;
|
||||
if (authStore) {
|
||||
isAuthenticated = !!authStore.token;
|
||||
console.log('🔐 Auth Store State:', authStore);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Could not access auth store:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get app mode store state if available
|
||||
const appModeStore = window.__pinia?.state.value?.appMode;
|
||||
if (appModeStore) {
|
||||
appMode = appModeStore.mode;
|
||||
console.log('🎛️ App Mode Store State:', appModeStore);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Could not access app mode store:', e.message);
|
||||
}
|
||||
|
||||
console.log('📋 Authentication Status:');
|
||||
console.log(' • Token in localStorage:', token ? `YES (${token.substring(0, 20)}...)` : 'NO');
|
||||
console.log(' • isAdmin flag:', isAdmin);
|
||||
console.log(' • UI Mode in localStorage:', uiMode || 'NOT SET');
|
||||
console.log(' • Auth Store isAuthenticated:', isAuthenticated);
|
||||
console.log(' • App Mode Store mode:', appMode);
|
||||
console.log(' • Route requiresAuth:', to.meta.requiresAuth || false);
|
||||
console.log(' • Route requiresAdmin:', to.meta.requiresAdmin || false);
|
||||
|
||||
// Auth check
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
console.warn('❌ AUTH FAILED: Route requires auth but no token found');
|
||||
console.warn(` ↳ Redirecting to /login (from ${to.path})`);
|
||||
console.groupEnd();
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin check
|
||||
if (to.meta.requiresAdmin && !isAdmin) {
|
||||
console.warn('❌ ADMIN CHECK FAILED: Route requires admin but user is not admin');
|
||||
console.warn(` ↳ Redirecting to / (from ${to.path})`);
|
||||
console.groupEnd();
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// UI mode selection logic
|
||||
if (to.meta.requiresAuth && token) {
|
||||
const hasMode = hasUIModeSelected();
|
||||
console.log('🎯 UI Mode Check:');
|
||||
console.log(' • hasUIModeSelected():', hasMode);
|
||||
console.log(' • Target route name:', to.name);
|
||||
|
||||
// If user tries to access dashboard without mode selection, redirect to profile-select
|
||||
if (to.name === 'Dashboard' && !hasMode) {
|
||||
console.warn('⚠️ UI MODE MISSING: Dashboard access without mode selection');
|
||||
console.warn(` ↳ Redirecting to /profile-select (from ${to.path})`);
|
||||
console.groupEnd();
|
||||
next('/profile-select');
|
||||
return;
|
||||
}
|
||||
|
||||
// If user tries to access profile-select but already has mode, redirect to dashboard
|
||||
if (to.name === 'ProfileSelect' && hasMode) {
|
||||
console.warn('⚠️ UI MODE ALREADY SET: Profile-select access with existing mode');
|
||||
console.warn(` ↳ Redirecting to / (from ${to.path})`);
|
||||
console.groupEnd();
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ ALL CHECKS PASSED: Allowing navigation to', to.path);
|
||||
console.groupEnd();
|
||||
next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
167
old_maps/frontend_old/Archive/src/services/api.js
Normal file
167
old_maps/frontend_old/Archive/src/services/api.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// Create axios instance with base URL from environment variable
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
console.group('📤 Axios Request Interceptor - Flight Recorder');
|
||||
console.log('📊 Request Details:');
|
||||
console.log(' • URL:', config.url);
|
||||
console.log(' • Method:', config.method);
|
||||
console.log(' • Base URL:', config.baseURL);
|
||||
|
||||
// Get token from localStorage (check both 'token' and 'access_token' for compatibility)
|
||||
if (typeof window !== 'undefined') {
|
||||
let token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
console.log('⚠️ Using access_token (legacy) instead of token');
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
console.log('🔐 Adding Authorization header with token');
|
||||
console.log(' • Token present:', token.substring(0, 20) + '...');
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.log('⚠️ No auth token found in localStorage');
|
||||
console.log(' • token key:', localStorage.getItem('token') ? 'PRESENT' : 'MISSING');
|
||||
console.log(' • access_token key:', localStorage.getItem('access_token') ? 'PRESENT' : 'MISSING');
|
||||
}
|
||||
} else {
|
||||
console.log('🌐 Window not available (SSR)');
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ Request interceptor error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.group('🚨 Axios Response Interceptor - Flight Recorder');
|
||||
console.log('📊 Interceptor triggered for error:', error);
|
||||
|
||||
if (error.response) {
|
||||
console.log('📡 Response Details:');
|
||||
console.log(' • Status:', error.response.status);
|
||||
console.log(' • URL:', error.config?.url);
|
||||
console.log(' • Method:', error.config?.method);
|
||||
console.log(' • Headers:', error.config?.headers);
|
||||
|
||||
if (error.response.status === 401) {
|
||||
console.warn('🔐 401 UNAUTHORIZED DETECTED!');
|
||||
console.warn(' ↳ This will trigger logout and redirect');
|
||||
|
||||
// Log current auth state before clearing
|
||||
const token = localStorage.getItem('token');
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
console.log(' ↳ Current localStorage state:');
|
||||
console.log(' - token:', token ? `YES (${token.substring(0, 20)}...)` : 'NO');
|
||||
console.log(' - access_token:', accessToken ? `YES (${accessToken.substring(0, 20)}...)` : 'NO');
|
||||
|
||||
// Handle unauthorized - clear ALL auth tokens and redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log(' ↳ Clearing auth tokens from localStorage...');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('is_admin');
|
||||
localStorage.removeItem('user_email');
|
||||
localStorage.removeItem('user_role');
|
||||
|
||||
// Also try to call auth store logout if available
|
||||
try {
|
||||
const authStore = window.__pinia?.state.value?.auth;
|
||||
if (authStore && authStore.logout) {
|
||||
console.log(' ↳ Calling auth store logout()...');
|
||||
// We can't call the function directly from here, but we can dispatch an event
|
||||
window.dispatchEvent(new CustomEvent('force-logout'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(' ↳ Could not access auth store:', e.message);
|
||||
}
|
||||
|
||||
console.warn(' ↳ Redirecting to /login');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else if (error.response.status === 403) {
|
||||
console.warn('🔒 403 FORBIDDEN DETECTED');
|
||||
console.warn(' ↳ User lacks permissions for this resource');
|
||||
} else if (error.response.status === 404) {
|
||||
console.warn('🔍 404 NOT FOUND DETECTED');
|
||||
console.warn(' ↳ API endpoint or resource not found');
|
||||
} else if (error.response.status >= 500) {
|
||||
console.error('💥 SERVER ERROR DETECTED (5xx)');
|
||||
console.error(' ↳ Backend server issue');
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error('🌐 NETWORK ERROR: Request was made but no response received');
|
||||
console.error(' ↳ Possible network issue or CORS problem');
|
||||
} else {
|
||||
console.error('⚙️ SETUP ERROR: Error in request configuration');
|
||||
console.error(' ↳', error.message);
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
// Catalog API functions
|
||||
export const catalogApi = {
|
||||
async getMakes(vehicleClass = null) {
|
||||
const params = {}
|
||||
if (vehicleClass) {
|
||||
params.vehicle_class = vehicleClass
|
||||
}
|
||||
const response = await api.get('/api/v1/catalog/makes', { params })
|
||||
return response.data
|
||||
},
|
||||
async getModels(make, vehicleClass = null) {
|
||||
const params = { make }
|
||||
if (vehicleClass) {
|
||||
params.vehicle_class = vehicleClass
|
||||
}
|
||||
const response = await api.get('/api/v1/catalog/models', { params })
|
||||
return response.data
|
||||
},
|
||||
async getGenerations(make, model) {
|
||||
const response = await api.get('/api/v1/catalog/generations', { params: { make, model } })
|
||||
return response.data
|
||||
},
|
||||
async getEngines(make, model, gen) {
|
||||
const response = await api.get('/api/v1/catalog/engines', { params: { make, model, gen } })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Organization API functions
|
||||
export const organizationApi = {
|
||||
async getMyOrganizations() {
|
||||
const response = await api.get('/organizations/my')
|
||||
return response.data
|
||||
},
|
||||
async updateActiveOrganization(organizationId) {
|
||||
const response = await api.patch('/users/me/active-organization', {
|
||||
organization_id: organizationId
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
202
old_maps/frontend_old/Archive/src/stores/analyticsStore.js
Normal file
202
old_maps/frontend_old/Archive/src/stores/analyticsStore.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
export const useAnalyticsStore = defineStore('analytics', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Real data - initially empty, will be fetched from API
|
||||
const monthlyCosts = ref([])
|
||||
const fuelEfficiencyTrends = ref([])
|
||||
const costPerKmTrends = ref([])
|
||||
const funFacts = ref({
|
||||
totalKmDriven: 0,
|
||||
totalTreesSaved: 0,
|
||||
totalCo2Saved: 0,
|
||||
totalMoneySaved: 0,
|
||||
moonTrips: computed(() => Math.round(funFacts.value.totalKmDriven / 384400)),
|
||||
earthCircuits: computed(() => Math.round(funFacts.value.totalKmDriven / 40075)),
|
||||
})
|
||||
const businessMetrics = ref({
|
||||
fleetSize: 0,
|
||||
averageVehicleAge: 0,
|
||||
totalMonthlyCost: 0,
|
||||
averageCostPerKm: 0,
|
||||
utilizationRate: 0,
|
||||
downtimeHours: 0,
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const totalCosts = computed(() => {
|
||||
return monthlyCosts.value.reduce((sum, month) => sum + month.total, 0)
|
||||
})
|
||||
|
||||
const averageMonthlyCost = computed(() => {
|
||||
return monthlyCosts.value.length > 0 ? totalCosts.value / monthlyCosts.value.length : 0
|
||||
})
|
||||
|
||||
const averageFuelEfficiency = computed(() => {
|
||||
const sum = fuelEfficiencyTrends.value.reduce((acc, item) => acc + item.efficiency, 0)
|
||||
return fuelEfficiencyTrends.value.length > 0 ? sum / fuelEfficiencyTrends.value.length : 0
|
||||
})
|
||||
|
||||
const averageCostPerKm = computed(() => {
|
||||
const sum = costPerKmTrends.value.reduce((acc, item) => acc + item.cost, 0)
|
||||
return costPerKmTrends.value.length > 0 ? sum / costPerKmTrends.value.length : 0
|
||||
})
|
||||
|
||||
// Actions
|
||||
function addMonthlyCost(data) {
|
||||
monthlyCosts.value.push(data)
|
||||
}
|
||||
|
||||
function updateFuelEfficiency(month, efficiency) {
|
||||
const index = fuelEfficiencyTrends.value.findIndex(item => item.month === month)
|
||||
if (index !== -1) {
|
||||
fuelEfficiencyTrends.value[index].efficiency = efficiency
|
||||
}
|
||||
}
|
||||
|
||||
function updateFunFacts(newFacts) {
|
||||
Object.assign(funFacts.value, newFacts)
|
||||
}
|
||||
|
||||
// Real API fetch - NO MORE MOCK DATA
|
||||
async function fetchDashboardAnalytics() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Get auth token
|
||||
const token = authStore.token
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
// Call real backend API
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/analytics/dashboard`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch analytics: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AnalyticsStore: Fetched dashboard analytics', data)
|
||||
|
||||
// Transform API response to frontend format
|
||||
monthlyCosts.value = data.monthly_costs || []
|
||||
fuelEfficiencyTrends.value = data.fuel_efficiency_trends || []
|
||||
costPerKmTrends.value = data.cost_per_km_trends || []
|
||||
|
||||
if (data.fun_facts) {
|
||||
funFacts.value = {
|
||||
totalKmDriven: data.fun_facts.total_km_driven || 0,
|
||||
totalTreesSaved: data.fun_facts.total_trees_saved || 0,
|
||||
totalCo2Saved: data.fun_facts.total_co2_saved || 0,
|
||||
totalMoneySaved: data.fun_facts.total_money_saved || 0,
|
||||
moonTrips: computed(() => Math.round((data.fun_facts.total_km_driven || 0) / 384400)),
|
||||
earthCircuits: computed(() => Math.round((data.fun_facts.total_km_driven || 0) / 40075)),
|
||||
}
|
||||
}
|
||||
|
||||
if (data.business_metrics) {
|
||||
businessMetrics.value = {
|
||||
fleetSize: data.business_metrics.fleet_size || 0,
|
||||
averageVehicleAge: data.business_metrics.average_vehicle_age || 0,
|
||||
totalMonthlyCost: data.business_metrics.total_monthly_cost || 0,
|
||||
averageCostPerKm: data.business_metrics.average_cost_per_km || 0,
|
||||
utilizationRate: data.business_metrics.utilization_rate || 0,
|
||||
downtimeHours: data.business_metrics.downtime_hours || 0,
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('AnalyticsStore: Error fetching analytics', err)
|
||||
error.value = err.message
|
||||
// Keep empty data (no mock fallback)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch vehicle-specific analytics
|
||||
async function fetchVehicleAnalytics(vehicleId) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const token = authStore.token
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
// Call vehicle summary endpoint
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/analytics/${vehicleId}/summary`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch vehicle analytics: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AnalyticsStore: Fetched vehicle analytics', data)
|
||||
|
||||
// For now, just return the data - frontend components can use it directly
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('AnalyticsStore: Error fetching vehicle analytics', err)
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fleet analytics (aggregated)
|
||||
async function fetchFleetAnalytics() {
|
||||
// For now, use the dashboard endpoint which includes fleet metrics
|
||||
return fetchDashboardAnalytics()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
monthlyCosts,
|
||||
fuelEfficiencyTrends,
|
||||
costPerKmTrends,
|
||||
funFacts,
|
||||
businessMetrics,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
totalCosts,
|
||||
averageMonthlyCost,
|
||||
averageFuelEfficiency,
|
||||
averageCostPerKm,
|
||||
|
||||
// Actions
|
||||
addMonthlyCost,
|
||||
updateFuelEfficiency,
|
||||
updateFunFacts,
|
||||
fetchDashboardAnalytics,
|
||||
fetchVehicleAnalytics,
|
||||
fetchFleetAnalytics,
|
||||
}
|
||||
})
|
||||
135
old_maps/frontend_old/Archive/src/stores/appModeStore.js
Normal file
135
old_maps/frontend_old/Archive/src/stores/appModeStore.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '@/services/api'
|
||||
import { useGarageStore } from './garageStore'
|
||||
|
||||
export const useAppModeStore = defineStore('appMode', () => {
|
||||
// State
|
||||
const mode = ref('personal') // backend compatible values: 'personal' or 'fleet'
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Getters
|
||||
const isPrivateGarage = computed(() => mode.value === 'personal')
|
||||
const isCorporateFleet = computed(() => mode.value === 'fleet')
|
||||
|
||||
// Actions
|
||||
async function setMode(newMode) {
|
||||
// Map UI values to backend compatible values
|
||||
let backendMode = newMode
|
||||
if (newMode === 'private_garage') {
|
||||
backendMode = 'personal'
|
||||
} else if (newMode === 'corporate_fleet') {
|
||||
backendMode = 'fleet'
|
||||
}
|
||||
|
||||
if (!['personal', 'fleet'].includes(backendMode)) {
|
||||
console.error('Invalid mode:', newMode)
|
||||
return
|
||||
}
|
||||
mode.value = backendMode
|
||||
persistMode(backendMode)
|
||||
await saveModeToBackend(backendMode)
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
const newMode = mode.value === 'personal' ? 'fleet' : 'personal'
|
||||
setMode(newMode)
|
||||
}
|
||||
|
||||
// SSR-safe localStorage persistence
|
||||
function persistMode(mode) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('ui_mode', mode)
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialMode() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('ui_mode')
|
||||
// Map UI values to backend compatible values
|
||||
if (saved === 'private_garage' || saved === 'personal') {
|
||||
return 'personal'
|
||||
} else if (saved === 'corporate_fleet' || saved === 'fleet') {
|
||||
return 'fleet'
|
||||
}
|
||||
}
|
||||
// Default mode
|
||||
return 'personal'
|
||||
}
|
||||
|
||||
// Load user preferences from backend on app startup
|
||||
async function loadModeFromBackend() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await api.get('/users/me')
|
||||
const user = response.data
|
||||
if (user.ui_mode && ['personal', 'fleet'].includes(user.ui_mode)) {
|
||||
mode.value = user.ui_mode
|
||||
persistMode(user.ui_mode)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load UI mode from backend, using local storage', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save mode to backend via PATCH /users/me/preferences
|
||||
async function saveModeToBackend(newMode) {
|
||||
try {
|
||||
await api.patch('/users/me/preferences', {
|
||||
ui_mode: newMode
|
||||
})
|
||||
|
||||
// Also update active organization based on mode
|
||||
// When switching to fleet mode, we need to set an active organization
|
||||
// When switching to personal mode, we should clear the active organization
|
||||
if (newMode === 'fleet') {
|
||||
// Try to get the user's active organization from auth store
|
||||
// For now, we'll just make sure the backend knows we're in fleet mode
|
||||
// The actual organization selection should happen elsewhere
|
||||
console.log('Switched to fleet mode - organization selection may be needed')
|
||||
} else if (newMode === 'personal') {
|
||||
// Clear active organization when switching to personal mode
|
||||
try {
|
||||
await api.patch('/users/me/active-organization', {
|
||||
organization_id: null
|
||||
})
|
||||
console.log('Cleared active organization for personal mode')
|
||||
} catch (orgError) {
|
||||
console.warn('Failed to clear active organization', orgError)
|
||||
// Non-critical error, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the garage store to reflect the new scope
|
||||
try {
|
||||
const garageStore = useGarageStore()
|
||||
await garageStore.fetchVehicles()
|
||||
console.log('Garage store refreshed after mode change')
|
||||
} catch (refreshError) {
|
||||
console.warn('Failed to refresh garage store', refreshError)
|
||||
// Non-critical error, continue
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save UI mode to backend', error)
|
||||
// Optionally revert local state? For now just log.
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
mode.value = getInitialMode()
|
||||
// Load from backend after store creation (call in component's onMounted)
|
||||
// We expose loadModeFromBackend for components to call
|
||||
|
||||
return {
|
||||
mode,
|
||||
isLoading,
|
||||
isPrivateGarage,
|
||||
isCorporateFleet,
|
||||
setMode,
|
||||
toggleMode,
|
||||
loadModeFromBackend,
|
||||
}
|
||||
})
|
||||
322
old_maps/frontend_old/Archive/src/stores/authStore.js
Normal file
322
old_maps/frontend_old/Archive/src/stores/authStore.js
Normal file
@@ -0,0 +1,322 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '../router'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const isAdmin = ref(localStorage.getItem('is_admin') === 'true')
|
||||
const userEmail = ref(localStorage.getItem('user_email') || '')
|
||||
const userRole = ref(localStorage.getItem('user_role') || '')
|
||||
const userProfile = ref(null) // Full user profile from /users/me
|
||||
const activeOrgId = ref(localStorage.getItem('active_org_id') || null) // Active organization ID
|
||||
|
||||
// Getters
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isTester = computed(() => userEmail.value === 'tester_pro@profibot.hu' || userRole.value === 'tester')
|
||||
const displayName = computed(() => {
|
||||
if (isTester.value) return 'TESTER PRO'
|
||||
if (isAdmin.value) return 'ADMIN'
|
||||
return 'USER'
|
||||
})
|
||||
|
||||
// Actions
|
||||
const login = async (email, password) => {
|
||||
console.log('AuthStore: Real API login attempt for', email)
|
||||
|
||||
try {
|
||||
// Prepare URL-encoded form data for OAuth2 password grant
|
||||
// FastAPI's OAuth2PasswordRequestForm expects application/x-www-form-urlencoded
|
||||
const params = new URLSearchParams()
|
||||
params.append('username', email)
|
||||
params.append('password', password)
|
||||
|
||||
// Call real backend API
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu';
|
||||
const response = await fetch(`${apiBaseUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('AuthStore: Login API error', response.status, errorText)
|
||||
throw new Error(`Login failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AuthStore: Login API response', data)
|
||||
|
||||
// Extract token and user info
|
||||
const accessToken = data.access_token
|
||||
const refreshToken = data.refresh_token
|
||||
const tokenType = data.token_type
|
||||
const isActive = data.is_active
|
||||
|
||||
// We need to decode the JWT token to get user role and info
|
||||
// For now, we'll make a separate API call to get user info
|
||||
// Or we can parse the JWT token (simple base64 decode)
|
||||
let roleValue = 'user'
|
||||
let adminFlag = false
|
||||
|
||||
try {
|
||||
// Decode JWT token to get payload
|
||||
const tokenParts = accessToken.split('.')
|
||||
if (tokenParts.length === 3) {
|
||||
const payload = JSON.parse(atob(tokenParts[1]))
|
||||
roleValue = payload.role || 'user'
|
||||
adminFlag = roleValue === 'admin' || roleValue === 'superadmin'
|
||||
console.log('AuthStore: Decoded JWT payload', payload)
|
||||
}
|
||||
} catch (decodeError) {
|
||||
console.warn('AuthStore: Could not decode JWT token', decodeError)
|
||||
// Fallback: Make API call to get user info
|
||||
// For now, we'll use a default role
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('token', accessToken)
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
localStorage.setItem('is_admin', adminFlag.toString())
|
||||
localStorage.setItem('user_email', email)
|
||||
localStorage.setItem('user_role', roleValue)
|
||||
|
||||
// Update store state
|
||||
token.value = accessToken
|
||||
isAdmin.value = adminFlag
|
||||
userEmail.value = email
|
||||
userRole.value = roleValue
|
||||
|
||||
console.log('AuthStore: State updated, redirecting to /profile-select')
|
||||
|
||||
// Redirect to profile-select (as per router logic)
|
||||
try {
|
||||
await router.push('/profile-select')
|
||||
console.log('AuthStore: Redirect successful')
|
||||
} catch (error) {
|
||||
console.error('AuthStore: Router redirect failed:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return { success: true, token: accessToken, isAdmin: adminFlag, role: roleValue }
|
||||
|
||||
} catch (error) {
|
||||
console.error('AuthStore: Login failed', error)
|
||||
throw error // Re-throw the error instead of falling back to mock
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('is_admin')
|
||||
localStorage.removeItem('user_email')
|
||||
localStorage.removeItem('user_role')
|
||||
localStorage.removeItem('ui_mode') // Also clear UI mode on logout
|
||||
localStorage.removeItem('active_org_id') // Clear active organization ID
|
||||
|
||||
// Reset store state
|
||||
token.value = ''
|
||||
isAdmin.value = false
|
||||
userEmail.value = ''
|
||||
userRole.value = ''
|
||||
userProfile.value = null
|
||||
activeOrgId.value = null
|
||||
|
||||
// Redirect to login
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
if (!token.value) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu';
|
||||
const response = await fetch(`${apiBaseUrl}/users/me`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.value}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user profile: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AuthStore: Fetched user profile', data)
|
||||
|
||||
// Update user profile state
|
||||
userProfile.value = data
|
||||
|
||||
// Also update email and role from profile if available
|
||||
if (data.email && !userEmail.value) {
|
||||
userEmail.value = data.email
|
||||
localStorage.setItem('user_email', data.email)
|
||||
}
|
||||
|
||||
if (data.role && !userRole.value) {
|
||||
userRole.value = data.role
|
||||
localStorage.setItem('user_role', data.role)
|
||||
isAdmin.value = data.role === 'admin' || data.role === 'superadmin'
|
||||
localStorage.setItem('is_admin', isAdmin.value.toString())
|
||||
}
|
||||
|
||||
// Store active organization ID if available
|
||||
if (data.active_organization_id !== undefined) {
|
||||
activeOrgId.value = data.active_organization_id
|
||||
localStorage.setItem('active_org_id', data.active_organization_id)
|
||||
console.log('AuthStore: Set active organization ID:', data.active_organization_id)
|
||||
} else if (data.scope_id) {
|
||||
// Fallback to scope_id if active_organization_id is not provided
|
||||
activeOrgId.value = data.scope_id
|
||||
localStorage.setItem('active_org_id', data.scope_id)
|
||||
console.log('AuthStore: Set active organization ID from scope_id:', data.scope_id)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('AuthStore: Error fetching user profile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Update active organization
|
||||
const updateActiveOrganization = async (organizationId) => {
|
||||
if (!token.value) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu';
|
||||
const response = await fetch(`${apiBaseUrl}/users/me/active-organization`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.value}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
organization_id: organizationId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update active organization: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AuthStore: Updated active organization', data)
|
||||
|
||||
// Check if response contains new JWT token (backend now returns {user: {...}, access_token: "...", token_type: "bearer"})
|
||||
if (data.access_token) {
|
||||
console.log('AuthStore: Received new access token from organization switch')
|
||||
|
||||
// Update token in localStorage and store state
|
||||
localStorage.setItem('token', data.access_token)
|
||||
token.value = data.access_token
|
||||
|
||||
// Decode new token to update role if needed
|
||||
try {
|
||||
const tokenParts = data.access_token.split('.')
|
||||
if (tokenParts.length === 3) {
|
||||
const payload = JSON.parse(atob(tokenParts[1]))
|
||||
const roleValue = payload.role || 'user'
|
||||
const adminFlag = roleValue === 'admin' || roleValue === 'superadmin'
|
||||
|
||||
localStorage.setItem('user_role', roleValue)
|
||||
localStorage.setItem('is_admin', adminFlag.toString())
|
||||
|
||||
userRole.value = roleValue
|
||||
isAdmin.value = adminFlag
|
||||
console.log('AuthStore: Updated role from new token:', roleValue)
|
||||
}
|
||||
} catch (decodeError) {
|
||||
console.warn('AuthStore: Could not decode new JWT token', decodeError)
|
||||
}
|
||||
}
|
||||
|
||||
// Update local state
|
||||
activeOrgId.value = organizationId
|
||||
localStorage.setItem('active_org_id', organizationId || '')
|
||||
|
||||
// Also update user profile to reflect changes
|
||||
await fetchUserProfile()
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('AuthStore: Error updating active organization', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuth = () => {
|
||||
// Sync with localStorage on page load
|
||||
const storedToken = localStorage.getItem('token') || ''
|
||||
const storedEmail = localStorage.getItem('user_email') || ''
|
||||
const storedRole = localStorage.getItem('user_role') || ''
|
||||
|
||||
// Try to decode JWT token to get role if not stored
|
||||
let decodedRole = storedRole
|
||||
let decodedIsAdmin = storedRole === 'admin' || storedRole === 'superadmin'
|
||||
|
||||
if (storedToken && !storedRole) {
|
||||
try {
|
||||
// Decode JWT token to get payload
|
||||
const tokenParts = storedToken.split('.')
|
||||
if (tokenParts.length === 3) {
|
||||
const payload = JSON.parse(atob(tokenParts[1]))
|
||||
decodedRole = payload.role || 'user'
|
||||
decodedIsAdmin = decodedRole === 'admin' || decodedRole === 'superadmin'
|
||||
console.log('AuthStore: Decoded JWT on checkAuth', payload)
|
||||
|
||||
// Update localStorage with decoded role
|
||||
localStorage.setItem('user_role', decodedRole)
|
||||
localStorage.setItem('is_admin', decodedIsAdmin.toString())
|
||||
}
|
||||
} catch (decodeError) {
|
||||
console.warn('AuthStore: Could not decode JWT token on checkAuth', decodeError)
|
||||
}
|
||||
} else if (storedToken && storedRole) {
|
||||
// Use stored role
|
||||
decodedIsAdmin = storedRole === 'admin' || storedRole === 'superadmin'
|
||||
}
|
||||
|
||||
token.value = storedToken
|
||||
isAdmin.value = decodedIsAdmin
|
||||
userEmail.value = storedEmail
|
||||
userRole.value = decodedRole || 'user'
|
||||
}
|
||||
|
||||
// Initialize on store creation
|
||||
checkAuth()
|
||||
|
||||
return {
|
||||
// State
|
||||
token,
|
||||
isAdmin,
|
||||
userEmail,
|
||||
userRole,
|
||||
userProfile,
|
||||
activeOrgId,
|
||||
|
||||
// Getters
|
||||
isLoggedIn,
|
||||
isTester,
|
||||
displayName,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
checkAuth,
|
||||
fetchUserProfile,
|
||||
updateActiveOrganization
|
||||
}
|
||||
})
|
||||
34
old_maps/frontend_old/Archive/src/stores/expenseStore.js
Normal file
34
old_maps/frontend_old/Archive/src/stores/expenseStore.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
export const useExpenseStore = defineStore('expense', () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function createExpense(expenseData) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await api.post('/expenses/', expenseData)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.detail || err.message
|
||||
// Check for DRAFT_LIMIT_REACHED error
|
||||
if (err.response?.status === 403 && err.response?.data?.detail === "DRAFT_LIMIT_REACHED") {
|
||||
const draftError = new Error('DRAFT_LIMIT_REACHED')
|
||||
draftError.response = err.response
|
||||
throw draftError
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
createExpense,
|
||||
}
|
||||
})
|
||||
158
old_maps/frontend_old/Archive/src/stores/gamificationStore.js
Normal file
158
old_maps/frontend_old/Archive/src/stores/gamificationStore.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
export const useGamificationStore = defineStore('gamification', () => {
|
||||
// State
|
||||
const achievements = ref([])
|
||||
const badges = ref([])
|
||||
const userStats = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const earnedAchievements = computed(() =>
|
||||
achievements.value.filter(a => a.is_earned)
|
||||
)
|
||||
|
||||
const lockedAchievements = computed(() =>
|
||||
achievements.value.filter(a => !a.is_earned)
|
||||
)
|
||||
|
||||
const totalAchievements = computed(() => achievements.value.length)
|
||||
|
||||
const earnedCount = computed(() => earnedAchievements.value.length)
|
||||
|
||||
const progressPercentage = computed(() => {
|
||||
if (totalAchievements.value === 0) return 0
|
||||
return Math.round((earnedCount.value / totalAchievements.value) * 100)
|
||||
})
|
||||
|
||||
const earnedBadges = computed(() =>
|
||||
badges.value.filter(b => b.is_earned)
|
||||
)
|
||||
|
||||
// Helper function for API calls (using centralized api instance)
|
||||
async function apiFetch(url, options = {}) {
|
||||
try {
|
||||
const response = await api.get(url, options)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
throw new Error(`API error ${error.response.status}: ${JSON.stringify(error.response.data)}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function fetchAchievements() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/achievements')
|
||||
achievements.value = data.achievements || []
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch achievements:', err)
|
||||
error.value = err.message
|
||||
// No mock fallback - let the error propagate
|
||||
achievements.value = []
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBadges() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/my-badges')
|
||||
badges.value = data.map(badge => ({
|
||||
id: badge.badge_id,
|
||||
title: badge.badge_name,
|
||||
description: badge.badge_description,
|
||||
icon_url: badge.badge_icon_url,
|
||||
is_earned: true,
|
||||
earned_date: badge.earned_at,
|
||||
category: 'badge'
|
||||
}))
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch badges:', err)
|
||||
error.value = err.message
|
||||
// No mock fallback - propagate error
|
||||
badges.value = []
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserStats() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/me')
|
||||
userStats.value = data
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user stats:', err)
|
||||
error.value = err.message
|
||||
// No mock fallback - propagate error
|
||||
userStats.value = null
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllGamificationData() {
|
||||
await Promise.all([
|
||||
fetchAchievements(),
|
||||
fetchBadges(),
|
||||
fetchUserStats()
|
||||
])
|
||||
}
|
||||
|
||||
async function earnAchievement(id) {
|
||||
// In a real implementation, this would call an API endpoint
|
||||
// For now, we'll just update local state
|
||||
const achievement = achievements.value.find(a => a.id === id)
|
||||
if (achievement && !achievement.is_earned) {
|
||||
achievement.is_earned = true
|
||||
achievement.earned_date = new Date().toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
function resetAchievements() {
|
||||
achievements.value.forEach(a => {
|
||||
a.is_earned = false
|
||||
a.earned_date = null
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize store with data - RE-ENABLED after token fix
|
||||
fetchAllGamificationData()
|
||||
|
||||
return {
|
||||
achievements,
|
||||
badges,
|
||||
userStats,
|
||||
earnedAchievements,
|
||||
lockedAchievements,
|
||||
totalAchievements,
|
||||
earnedCount,
|
||||
progressPercentage,
|
||||
earnedBadges,
|
||||
isLoading,
|
||||
error,
|
||||
fetchAchievements,
|
||||
fetchBadges,
|
||||
fetchUserStats,
|
||||
fetchAllGamificationData,
|
||||
earnAchievement,
|
||||
resetAchievements
|
||||
}
|
||||
})
|
||||
298
old_maps/frontend_old/Archive/src/stores/garageStore.js
Normal file
298
old_maps/frontend_old/Archive/src/stores/garageStore.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
export const useGarageStore = defineStore('garage', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Real vehicle data - initially empty, will be fetched from API
|
||||
const vehicles = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const totalVehicles = computed(() => vehicles.value.length)
|
||||
const totalMonthlyExpense = computed(() =>
|
||||
vehicles.value.reduce((sum, vehicle) => sum + (vehicle.monthlyExpense || 0), 0)
|
||||
)
|
||||
const vehiclesNeedingService = computed(() =>
|
||||
vehicles.value.filter(v => v.status === 'Service Due' || v.status === 'Warning').length
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function addVehicle(vehicle) {
|
||||
// Real API call to POST /api/v1/assets (Thick Asset endpoint)
|
||||
const token = authStore.token
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
try {
|
||||
// Transform frontend vehicle data to Thick Asset API schema
|
||||
// Include all required fields for thick asset creation
|
||||
const payload = {
|
||||
// Core identification
|
||||
vin: vehicle.vin || null, // Send null for draft vehicles
|
||||
license_plate: vehicle.licensePlate || 'N/A',
|
||||
catalog_id: vehicle.catalogId || null,
|
||||
organization_id: vehicle.organizationId || authStore.activeOrgId,
|
||||
|
||||
// Ownership fields (required by AssetCreate schema)
|
||||
owner_org_id: vehicle.owner_org_id || null,
|
||||
operator_org_id: vehicle.operator_org_id || null,
|
||||
|
||||
// Thick Asset fields - send even if catalog_id is provided for completeness
|
||||
brand: vehicle.brand || vehicle.make || null,
|
||||
model: vehicle.model || null,
|
||||
vehicle_class: vehicle.vehicleClass || vehicle.class || null,
|
||||
fuel_type: vehicle.fuelType || vehicle.fuel || null,
|
||||
year_of_manufacture: vehicle.year || vehicle.yearOfManufacture || null,
|
||||
engine_capacity: vehicle.engineCapacity || null,
|
||||
power_kw: vehicle.powerKw || null,
|
||||
transmission: vehicle.transmission || null,
|
||||
body_type: vehicle.bodyType || null,
|
||||
color: vehicle.color || null,
|
||||
current_mileage: vehicle.currentMileage || vehicle.mileage || 0,
|
||||
|
||||
// Metadata
|
||||
status: vehicle.status || 'draft', // Default to draft for 2-step creation
|
||||
data_status: 'incomplete', // Will be updated by backend snapshot sync
|
||||
profile_completion_percentage: 0 // Will be calculated by backend
|
||||
}
|
||||
|
||||
// Remove null values to keep payload clean
|
||||
Object.keys(payload).forEach(key => {
|
||||
if (payload[key] === null || payload[key] === undefined) {
|
||||
delete payload[key]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/api/v1/assets/vehicles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Failed to add vehicle: ${response.status} ${response.statusText}`
|
||||
|
||||
// Handle specific error cases
|
||||
if (response.status === 409) {
|
||||
errorMessage = 'Duplicate VIN or license plate detected. Please check your input.'
|
||||
} else if (response.status === 429) {
|
||||
errorMessage = 'Rate limit exceeded. Please try again later.'
|
||||
} else if (response.status === 400) {
|
||||
errorMessage = `Invalid input: ${errorText}`
|
||||
} else if (response.status === 403) {
|
||||
errorMessage = 'Permission denied. You may have reached your vehicle limit.'
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('GarageStore: Thick Asset created successfully', data)
|
||||
|
||||
// After successful save, fetch fresh data from server to ensure consistency
|
||||
await fetchVehicles()
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('GarageStore: Error adding vehicle', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function removeVehicle(id) {
|
||||
// In a real app, this would be an API call to DELETE /assets/{id}
|
||||
vehicles.value = vehicles.value.filter(v => v.id !== id)
|
||||
}
|
||||
|
||||
function updateVehicle(id, updates) {
|
||||
// In a real app, this would be an API call to PATCH /assets/{id}
|
||||
const index = vehicles.value.findIndex(v => v.id === id)
|
||||
if (index !== -1) {
|
||||
vehicles.value[index] = { ...vehicles.value[index], ...updates }
|
||||
}
|
||||
}
|
||||
|
||||
function getVehicleById(id) {
|
||||
return vehicles.value.find(v => v.id === id)
|
||||
}
|
||||
|
||||
// Real API fetch - NO MORE MOCK DATA
|
||||
async function fetchVehicles() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Get auth token
|
||||
const token = authStore.token
|
||||
|
||||
if (!token) {
|
||||
console.error('GarageStore: No authentication token available')
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
console.log('GarageStore: Starting vehicle fetch with token', token.substring(0, 20) + '...')
|
||||
|
||||
// Call real backend API
|
||||
// First try the assets endpoint for user's vehicles
|
||||
const apiUrl = `${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/assets/vehicles`
|
||||
console.log('GarageStore: Fetching from primary endpoint:', apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('GarageStore: Primary endpoint response status:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
// If 404, try alternative endpoint
|
||||
if (response.status === 404) {
|
||||
console.log('GarageStore: Primary endpoint returned 404, trying user assets endpoint')
|
||||
// Try user assets endpoint
|
||||
const userApiUrl = `${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/users/me/assets`
|
||||
const userResponse = await fetch(userApiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('GarageStore: User assets endpoint response status:', userResponse.status, userResponse.statusText)
|
||||
|
||||
if (!userResponse.ok) {
|
||||
let errorBody = ''
|
||||
try {
|
||||
errorBody = await userResponse.text()
|
||||
} catch (e) {
|
||||
errorBody = 'Could not read error response'
|
||||
}
|
||||
console.error('GarageStore: User assets endpoint failed:', {
|
||||
status: userResponse.status,
|
||||
statusText: userResponse.statusText,
|
||||
body: errorBody
|
||||
})
|
||||
throw new Error(`Failed to fetch vehicles: ${userResponse.status} ${userResponse.statusText}`)
|
||||
}
|
||||
|
||||
const data = await userResponse.json()
|
||||
console.log('GarageStore: Fetched vehicles from user assets endpoint', data)
|
||||
vehicles.value = transformApiResponse(data)
|
||||
} else {
|
||||
let errorBody = ''
|
||||
try {
|
||||
errorBody = await response.text()
|
||||
} catch (e) {
|
||||
errorBody = 'Could not read error response'
|
||||
}
|
||||
console.error('GarageStore: Primary endpoint failed:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorBody
|
||||
})
|
||||
throw new Error(`Failed to fetch vehicles: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
} else {
|
||||
const data = await response.json()
|
||||
console.log('GarageStore: Fetched vehicles from assets endpoint', data)
|
||||
vehicles.value = transformApiResponse(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('GarageStore: Error fetching vehicles', err)
|
||||
console.error('GarageStore: Full error stack:', err.stack)
|
||||
error.value = err.message
|
||||
// NO MORE MOCK DATA FALLBACK - fail properly
|
||||
vehicles.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
console.log('GarageStore: Fetch completed, vehicles count:', vehicles.value.length)
|
||||
return vehicles.value
|
||||
}
|
||||
|
||||
// Helper function to transform API response to frontend format
|
||||
function transformApiResponse(data) {
|
||||
if (!Array.isArray(data)) {
|
||||
// If single object, wrap in array
|
||||
data = [data]
|
||||
}
|
||||
|
||||
return data.map(item => {
|
||||
// Safely extract catalog properties with optional chaining
|
||||
const catalog = item.catalog || {}
|
||||
const catalogMake = catalog?.make || catalog?.brand || null
|
||||
const catalogModel = catalog?.model || null
|
||||
const catalogFuelType = catalog?.fuel_type || catalog?.fuel || null
|
||||
const catalogYear = catalog?.year || catalog?.year_of_manufacture || null
|
||||
|
||||
// Safely extract profile completion percentage
|
||||
const profileCompletion = item.profile_completion_percentage ||
|
||||
item.profile_completion ||
|
||||
(item.profile_status === 'complete' ? 100 : 0) || 0
|
||||
|
||||
// Extract data_status from API (new field for vehicle data completeness)
|
||||
const dataStatus = item.data_status || item.status || (item.is_active ? 'active' : 'draft')
|
||||
|
||||
return {
|
||||
id: item.id || item.asset_id || 0,
|
||||
make: item.make || item.brand || item.vehicle_make || catalogMake || 'Unknown',
|
||||
model: item.model || item.vehicle_model || catalogModel || 'Unknown',
|
||||
year: item.year || item.year_of_manufacture || item.manufacture_year || catalogYear || 2023,
|
||||
licensePlate: item.license_plate || item.registration_number || 'N/A',
|
||||
status: item.status || (item.is_active ? 'active' : 'draft'),
|
||||
data_status: dataStatus,
|
||||
profile_completion_percentage: profileCompletion,
|
||||
monthlyExpense: item.monthly_expense || item.average_monthly_cost || 0,
|
||||
fuelType: item.fuel_type || item.fuel || catalogFuelType || 'Unknown',
|
||||
mileage: item.mileage || item.current_mileage || item.odometer_reading || 0,
|
||||
imageUrl: item.image_url || item.photo_url || getDefaultImage(item.make || item.brand || catalogMake),
|
||||
// Include catalog object for template access with null safety
|
||||
catalog: catalog || null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to get default image based on make
|
||||
function getDefaultImage(make) {
|
||||
// Handle null/undefined make with optional chaining and fallback
|
||||
const makeLower = (make || '').toLowerCase()
|
||||
if (makeLower.includes('bmw')) {
|
||||
return 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=400&h=300&fit=crop'
|
||||
} else if (makeLower.includes('audi')) {
|
||||
return 'https://images.unsplash.com/photo-1553440569-bcc63803a83d?w=400&h=300&fit=crop'
|
||||
} else if (makeLower.includes('mercedes')) {
|
||||
return 'https://images.unsplash.com/photo-1563720223485-8d6d5c5c8c3b?w=400&h=300&fit=crop'
|
||||
} else if (makeLower.includes('tesla')) {
|
||||
return 'https://images.unsplash.com/photo-1560958089-b8a1929cea89?w=400&h=300&fit=crop'
|
||||
}
|
||||
return 'https://images.unsplash.com/photo-1549399542-7e3f8b79c341?w=400&h=300&fit=crop'
|
||||
}
|
||||
|
||||
return {
|
||||
vehicles,
|
||||
isLoading,
|
||||
error,
|
||||
totalVehicles,
|
||||
totalMonthlyExpense,
|
||||
vehiclesNeedingService,
|
||||
addVehicle,
|
||||
removeVehicle,
|
||||
updateVehicle,
|
||||
getVehicleById,
|
||||
fetchVehicles
|
||||
}
|
||||
})
|
||||
261
old_maps/frontend_old/Archive/src/stores/quizStore.js
Normal file
261
old_maps/frontend_old/Archive/src/stores/quizStore.js
Normal file
@@ -0,0 +1,261 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useQuizStore = defineStore('quiz', () => {
|
||||
// State
|
||||
const userPoints = ref(0)
|
||||
const currentStreak = ref(0)
|
||||
const lastPlayedDate = ref(null)
|
||||
const questions = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const canPlayToday = computed(() => {
|
||||
if (!lastPlayedDate.value) return true
|
||||
const last = new Date(lastPlayedDate.value)
|
||||
const now = new Date()
|
||||
// Reset at midnight (different calendar day)
|
||||
const lastDay = last.toDateString()
|
||||
const today = now.toDateString()
|
||||
return lastDay !== today
|
||||
})
|
||||
|
||||
const totalQuestions = computed(() => questions.value.length)
|
||||
|
||||
// Helper function to get auth token
|
||||
function getAuthToken() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Try both token keys for compatibility
|
||||
return localStorage.getItem('token') || localStorage.getItem('auth_token')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Helper function for API calls
|
||||
async function apiFetch(url, options = {}) {
|
||||
const token = getAuthToken()
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}${url}`, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API error ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function fetchQuizStats() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/quiz/stats')
|
||||
userPoints.value = data.total_quiz_points || 0
|
||||
currentStreak.value = data.current_streak || 0
|
||||
lastPlayedDate.value = data.last_played || null
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch quiz stats:', err)
|
||||
error.value = err.message
|
||||
// Fallback to localStorage if API fails
|
||||
userPoints.value = getStoredPoints()
|
||||
currentStreak.value = getStoredStreak()
|
||||
lastPlayedDate.value = getStoredLastPlayedDate()
|
||||
return {
|
||||
total_quiz_points: userPoints.value,
|
||||
current_streak: currentStreak.value,
|
||||
last_played: lastPlayedDate.value,
|
||||
can_play_today: canPlayToday.value
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDailyQuiz() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/quiz/daily')
|
||||
questions.value = data.questions || []
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch daily quiz:', err)
|
||||
error.value = err.message
|
||||
// Fallback to mock questions if API fails
|
||||
questions.value = getMockQuestions()
|
||||
return {
|
||||
questions: questions.value,
|
||||
total_questions: questions.value.length,
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function answerQuestion(questionId, selectedOptionIndex) {
|
||||
try {
|
||||
const response = await apiFetch('/gamification/quiz/answer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
question_id: questionId,
|
||||
selected_option: selectedOptionIndex
|
||||
})
|
||||
})
|
||||
|
||||
if (response.is_correct) {
|
||||
userPoints.value += response.points_awarded
|
||||
currentStreak.value += 1
|
||||
persistState() // Update localStorage as fallback
|
||||
} else {
|
||||
currentStreak.value = 0
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
console.error('Failed to submit quiz answer:', err)
|
||||
error.value = err.message
|
||||
// Fallback to local logic
|
||||
return answerQuestionLocal(questionId, selectedOptionIndex)
|
||||
}
|
||||
}
|
||||
|
||||
async function completeDailyQuiz() {
|
||||
try {
|
||||
await apiFetch('/gamification/quiz/complete', {
|
||||
method: 'POST'
|
||||
})
|
||||
lastPlayedDate.value = new Date().toISOString()
|
||||
persistState()
|
||||
} catch (err) {
|
||||
console.error('Failed to complete daily quiz:', err)
|
||||
error.value = err.message
|
||||
// Fallback to local storage
|
||||
lastPlayedDate.value = new Date().toISOString()
|
||||
persistState()
|
||||
}
|
||||
}
|
||||
|
||||
// Local fallback functions
|
||||
function answerQuestionLocal(questionId, selectedOptionIndex) {
|
||||
const question = questions.value.find(q => q.id === questionId)
|
||||
if (!question) return { is_correct: false, correct_answer: -1, explanation: 'Question not found' }
|
||||
|
||||
const isCorrect = selectedOptionIndex === question.correctAnswer
|
||||
if (isCorrect) {
|
||||
userPoints.value += 10
|
||||
currentStreak.value += 1
|
||||
} else {
|
||||
currentStreak.value = 0
|
||||
}
|
||||
|
||||
persistState()
|
||||
return {
|
||||
is_correct: isCorrect,
|
||||
correct_answer: question.correctAnswer,
|
||||
points_awarded: isCorrect ? 10 : 0,
|
||||
explanation: question.explanation
|
||||
}
|
||||
}
|
||||
|
||||
function resetStreak() {
|
||||
currentStreak.value = 0
|
||||
persistState()
|
||||
}
|
||||
|
||||
function addPoints(points) {
|
||||
userPoints.value += points
|
||||
persistState()
|
||||
}
|
||||
|
||||
// SSR-safe localStorage persistence (fallback only)
|
||||
function persistState() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('quiz_points', userPoints.value.toString())
|
||||
localStorage.setItem('quiz_streak', currentStreak.value.toString())
|
||||
localStorage.setItem('quiz_last_played', lastPlayedDate.value)
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredPoints() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return parseInt(localStorage.getItem('quiz_points') || '0')
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getStoredStreak() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return parseInt(localStorage.getItem('quiz_streak') || '0')
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getStoredLastPlayedDate() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('quiz_last_played') || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getMockQuestions() {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
question: 'Melyik alkatrész felelős a motor levegő‑üzemanyag keverékének szabályozásáért?',
|
||||
options: ['Generátor', 'Lambda‑szonda', 'Féktárcsa', 'Olajszűrő'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'A lambda‑szonda méri a kipufogógáz oxigéntartalmát, és ezen alapul a befecskendezés.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: 'Mennyi ideig érvényes egy gépjármű műszaki vizsgája Magyarországon?',
|
||||
options: ['1 év', '2 év', '4 év', '6 év'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'A személygépkocsik műszaki vizsgája 2 évre érvényes, kivéve az újonnan forgalomba helyezett autókat.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
question: 'Melyik anyag NEM része a hibrid autók akkumulátorának?',
|
||||
options: ['Lítium', 'Nikkel', 'Ólom', 'Kobalt'],
|
||||
correctAnswer: 2,
|
||||
explanation: 'A hibrid és elektromos autók akkumulátoraiban általában lítium, nikkel és kobalt található, ólom az ólom‑savas akkukban van.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Initialize store with stats - DISABLED for debugging
|
||||
// fetchQuizStats()
|
||||
console.log('🚨 Quiz store: Auto-fetch DISABLED for debugging')
|
||||
|
||||
return {
|
||||
userPoints,
|
||||
currentStreak,
|
||||
lastPlayedDate,
|
||||
questions,
|
||||
canPlayToday,
|
||||
totalQuestions,
|
||||
isLoading,
|
||||
error,
|
||||
fetchQuizStats,
|
||||
fetchDailyQuiz,
|
||||
answerQuestion,
|
||||
completeDailyQuiz,
|
||||
resetStreak,
|
||||
addPoints
|
||||
}
|
||||
})
|
||||
43
old_maps/frontend_old/Archive/src/stores/themeStore.js
Normal file
43
old_maps/frontend_old/Archive/src/stores/themeStore.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useThemeStore = defineStore('theme', {
|
||||
state: () => ({
|
||||
currentTheme: 'luxury_showroom', // 'luxury_showroom' or 'rusty_workshop'
|
||||
}),
|
||||
getters: {
|
||||
isLuxury: (state) => state.currentTheme === 'luxury_showroom',
|
||||
isWorkshop: (state) => state.currentTheme === 'rusty_workshop',
|
||||
themeClasses: (state) => {
|
||||
if (state.currentTheme === 'luxury_showroom') {
|
||||
return {
|
||||
background: 'bg-gradient-to-br from-slate-900 via-slate-800 to-gray-900',
|
||||
text: 'text-amber-100',
|
||||
accent: 'text-amber-400',
|
||||
border: 'border-amber-700',
|
||||
card: 'bg-slate-800/70 backdrop-blur-lg border border-amber-700/30',
|
||||
button: 'bg-amber-700 hover:bg-amber-600 text-white',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
background: 'bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900',
|
||||
text: 'text-orange-100',
|
||||
accent: 'text-orange-400',
|
||||
border: 'border-orange-800',
|
||||
card: 'bg-gray-800/90 border border-dashed border-orange-700/50',
|
||||
button: 'bg-orange-800 hover:bg-orange-700 text-white',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
toggleTheme() {
|
||||
this.currentTheme = this.currentTheme === 'luxury_showroom' ? 'rusty_workshop' : 'luxury_showroom'
|
||||
},
|
||||
setTheme(theme) {
|
||||
if (['luxury_showroom', 'rusty_workshop'].includes(theme)) {
|
||||
this.currentTheme = theme
|
||||
}
|
||||
},
|
||||
},
|
||||
persist: true, // optional: if using pinia-plugin-persistedstate
|
||||
})
|
||||
109
old_maps/frontend_old/Archive/src/style.css
Executable file
109
old_maps/frontend_old/Archive/src/style.css
Executable file
@@ -0,0 +1,109 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Premium global styles */
|
||||
body {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); /* slate-50 to slate-100 */
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Subtle grain/noise texture overlay for premium feel */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
/* Global button micro-animations */
|
||||
button,
|
||||
[role="button"],
|
||||
.btn,
|
||||
.button {
|
||||
transition: all 0.2s ease-in-out !important;
|
||||
}
|
||||
|
||||
button:active,
|
||||
[role="button"]:active,
|
||||
.btn:active,
|
||||
.button:active {
|
||||
transform: scale(0.97) !important;
|
||||
}
|
||||
|
||||
/* Smooth scroll */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background-color: rgba(59, 130, 246, 0.2); /* blue-400 with opacity */
|
||||
color: #1e293b; /* slate-800 */
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9; /* slate-100 */
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1; /* slate-300 */
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8; /* slate-400 */
|
||||
}
|
||||
|
||||
/* Premium focus styles */
|
||||
*:focus {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
*:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
a, button, input, select, textarea {
|
||||
transition-property: color, background-color, border-color, transform, box-shadow;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
/* Fix unreadable yellow input text - change to dark blue */
|
||||
input, select, textarea {
|
||||
color: #1e3a8a; /* dark blue */
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #3b82f6; /* medium blue */
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Premium card hover effects */
|
||||
.hover-lift {
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
74
old_maps/frontend_old/Archive/src/views/AddExpense.vue
Executable file
74
old_maps/frontend_old/Archive/src/views/AddExpense.vue
Executable file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-xl border border-gray-100 mt-10">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6 flex items-center gap-2">
|
||||
<span>💸</span> Add Expense
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Kategória</label>
|
||||
<select v-model="form.category" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2.5 border">
|
||||
<option value="REFUELING">⛽ Tankolás</option>
|
||||
<option value="SERVICE">🔧 Szerviz</option>
|
||||
<option value="INSURANCE">🛡️ Biztosítás</option>
|
||||
<option value="TOLL">🛣️ Útdíj / Matrica</option>
|
||||
<option value="FINE">👮 Büntetés</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Összeg (Ft)</label>
|
||||
<input v-model="form.amount" type="number" required class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2.5 border" placeholder="Pl: 25000">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Km óra állása</label>
|
||||
<input v-model="form.odometer_value" type="number" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2.5 border" placeholder="Pl: 145200">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full bg-blue-600 text-white font-bold py-3 rounded-lg hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200">
|
||||
Mentés rögzítése
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const form = ref({
|
||||
category: 'REFUELING',
|
||||
amount: null,
|
||||
odometer_value: null,
|
||||
vehicle_id: 'auto-uuid-helye', // Ezt majd a routerből kapjuk
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
// Map frontend fields to backend schema
|
||||
const categoryMap = {
|
||||
'REFUELING': 'fuel',
|
||||
'SERVICE': 'service',
|
||||
'INSURANCE': 'insurance',
|
||||
'TOLL': 'toll',
|
||||
'FINE': 'fine'
|
||||
}
|
||||
const payload = {
|
||||
cost_type: categoryMap[form.value.category] || form.value.category.toLowerCase(),
|
||||
amount_local: form.value.amount,
|
||||
currency_local: 'HUF',
|
||||
mileage_at_cost: form.value.odometer_value,
|
||||
date: new Date(form.value.date).toISOString(),
|
||||
asset_id: form.value.vehicle_id,
|
||||
description: null,
|
||||
data: {}
|
||||
}
|
||||
await api.post('/expenses/', payload)
|
||||
alert("Sikeresen mentve!")
|
||||
} catch (err) {
|
||||
alert("Hiba történt a mentéskor.")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
71
old_maps/frontend_old/Archive/src/views/AddVehicle.vue
Executable file
71
old_maps/frontend_old/Archive/src/views/AddVehicle.vue
Executable file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto bg-white p-8 rounded-2xl shadow-lg mt-6">
|
||||
<h2 class="text-2xl font-bold mb-6 flex items-center gap-2">
|
||||
<span class="text-3xl">➕</span> Új jármű hozzáadása
|
||||
</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Márka keresése</label>
|
||||
<input v-model="searchQuery" @input="searchBrands" type="text" placeholder="Pl: Audi, Toyota..." class="mt-1 block w-full p-3 border rounded-lg shadow-sm" />
|
||||
|
||||
<div v-if="brands.length > 0" class="mt-2 border rounded-lg divide-y bg-gray-50">
|
||||
<div v-for="brand in brands" :key="brand.id" @click="selectBrand(brand)" class="p-3 hover:bg-blue-100 cursor-pointer flex justify-between">
|
||||
<span class="font-bold">{{ brand.name }}</span>
|
||||
<span class="text-xs text-gray-400 italic">{{ brand.country_of_origin }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedBrand" class="p-4 bg-blue-50 rounded-lg border border-blue-200 animate-fade-in">
|
||||
<p class="font-bold text-blue-800">Kiválasztott márka: {{ selectedBrand.name }}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<input v-model="form.model_name" type="text" placeholder="Modell (Pl: A4, Corolla)" class="p-2 border rounded" />
|
||||
<input v-model="form.plate" type="text" placeholder="Rendszám" class="p-2 border rounded uppercase" />
|
||||
<input v-model="form.vin" type="text" placeholder="Alvázszám (opcionális)" class="p-2 border rounded uppercase" />
|
||||
<input v-model="form.current_odo" type="number" placeholder="Aktuális km állás" class="p-2 border rounded" />
|
||||
</div>
|
||||
<button @click="saveVehicle" class="w-full mt-6 bg-blue-700 text-white py-3 rounded-lg font-bold shadow-lg">Jármű mentése a Széfbe</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '@/services/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const searchQuery = ref('')
|
||||
const brands = ref([])
|
||||
const selectedBrand = ref(null)
|
||||
const form = ref({ model_name: '', plate: '', vin: '', current_odo: 0 })
|
||||
|
||||
const searchBrands = async () => {
|
||||
if (searchQuery.value.length < 2) { brands.value = []; return; }
|
||||
const res = await api.get(`/vehicles/search/brands?q=${searchQuery.value}`)
|
||||
brands.value = res.data.data
|
||||
}
|
||||
|
||||
const selectBrand = (brand) => {
|
||||
selectedBrand.value = brand
|
||||
brands.value = []
|
||||
}
|
||||
|
||||
const saveVehicle = async () => {
|
||||
try {
|
||||
await api.post('/assets/vehicles', {
|
||||
vin: form.value.vin || null,
|
||||
license_plate: form.value.plate || 'N/A',
|
||||
catalog_id: null, // draft mode, no catalog mapping yet
|
||||
organization_id: authStore.activeOrgId // Use active organization ID, must be present
|
||||
})
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
alert("Hiba a mentés során.")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
149
old_maps/frontend_old/Archive/src/views/Dashboard.vue
Executable file
149
old_maps/frontend_old/Archive/src/views/Dashboard.vue
Executable file
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import VehicleShowcase from '@/components/garage/VehicleShowcase.vue'
|
||||
import AchievementShowcase from '@/components/gamification/AchievementShowcase.vue'
|
||||
import AnalyticsDashboard from '@/components/analytics/AnalyticsDashboard.vue'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const report = ref(null)
|
||||
const loading = ref(true)
|
||||
const themeStore = useThemeStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const themeClasses = computed(() => themeStore.themeClasses)
|
||||
|
||||
onMounted(async () => {
|
||||
const token = localStorage.getItem('token')
|
||||
try {
|
||||
// Fetch user profile
|
||||
await authStore.fetchUserProfile()
|
||||
|
||||
// Fetch report summary
|
||||
const res = await axios.get(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/reports/summary/latest`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
report.value = res.data
|
||||
} catch (err) {
|
||||
console.log("Még nincs rögzített adat.")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading" :class="['space-y-8 p-6 min-h-screen transition-all duration-500', themeClasses.background]">
|
||||
<!-- Vehicle Showcase (Dual-UI) -->
|
||||
<VehicleShowcase />
|
||||
|
||||
<!-- Gamification Showcase (Dual-UI) -->
|
||||
<div class="backdrop-blur-md bg-white/80 rounded-2xl shadow-xl p-6 border border-slate-200/60 transition-all duration-300 hover:shadow-2xl hover:border-slate-300/80">
|
||||
<AchievementShowcase />
|
||||
</div>
|
||||
|
||||
<!-- Analytics & TCO Dashboard (EPIC 11 - Ticket #126) -->
|
||||
<div class="backdrop-blur-md bg-gradient-to-br from-white to-blue-50/80 rounded-2xl shadow-xl p-6 border border-blue-200/60 transition-all duration-300 hover:shadow-2xl hover:border-blue-300/80">
|
||||
<AnalyticsDashboard />
|
||||
</div>
|
||||
|
||||
<!-- Existing Report Section (if data exists) -->
|
||||
<div v-if="report" class="backdrop-blur-md bg-white/90 rounded-2xl shadow-lg p-6 border border-slate-200/60 transition-all duration-300">
|
||||
<h2 class="text-2xl font-bold text-slate-900 mb-6">Financial Summary</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-gradient-to-br from-blue-50/80 to-blue-100/50 p-5 rounded-xl border border-blue-200/40 transition-all duration-200 hover:scale-[1.02] hover:shadow-md">
|
||||
<div class="text-sm text-blue-700 font-medium">Total Expenses</div>
|
||||
<div class="text-2xl font-bold text-slate-900">€{{ report.total_expenses?.toLocaleString() || '0' }}</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-emerald-50/80 to-emerald-100/50 p-5 rounded-xl border border-emerald-200/40 transition-all duration-200 hover:scale-[1.02] hover:shadow-md">
|
||||
<div class="text-sm text-emerald-700 font-medium">Monthly Average</div>
|
||||
<div class="text-2xl font-bold text-slate-900">€{{ report.monthly_average?.toLocaleString() || '0' }}</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-violet-50/80 to-violet-100/50 p-5 rounded-xl border border-violet-200/40 transition-all duration-200 hover:scale-[1.02] hover:shadow-md">
|
||||
<div class="text-sm text-violet-700 font-medium">Vehicles Tracked</div>
|
||||
<div class="text-2xl font-bold text-slate-900">{{ report.vehicle_count || '0' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State (no report data) -->
|
||||
<div v-else class="text-center py-12 bg-gradient-to-br from-slate-50/80 to-white rounded-2xl border border-slate-200/60 backdrop-blur-sm">
|
||||
<span class="text-6xl text-slate-300">📊</span>
|
||||
<h2 class="text-xl font-bold text-slate-500 mt-4">Még nincsenek rögzített költségeid.</h2>
|
||||
<p class="text-slate-600 mt-2 mb-6">Start tracking your vehicle expenses to see insights here.</p>
|
||||
<router-link
|
||||
to="/expenses"
|
||||
class="inline-block px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold rounded-lg transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg"
|
||||
>
|
||||
Kezdd el itt! →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<router-link
|
||||
to="/expenses/add"
|
||||
class="backdrop-blur-sm bg-white/90 p-6 rounded-xl shadow-sm border border-slate-200/60 hover:shadow-lg transition-all duration-200 hover:scale-[1.02] active:scale-95 group"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg mr-4 group-hover:from-blue-200 group-hover:to-blue-300 transition-all duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900">Add Expense</h3>
|
||||
<p class="text-slate-600 text-sm">Record fuel, maintenance, etc.</p>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/vehicles/add"
|
||||
class="backdrop-blur-sm bg-white/90 p-6 rounded-xl shadow-sm border border-slate-200/60 hover:shadow-lg transition-all duration-200 hover:scale-[1.02] active:scale-95 group"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-gradient-to-br from-emerald-100 to-emerald-200 rounded-lg mr-4 group-hover:from-emerald-200 group-hover:to-emerald-300 transition-all duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900">Add Vehicle</h3>
|
||||
<p class="text-slate-600 text-sm">Register a new vehicle</p>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/expenses"
|
||||
class="backdrop-blur-sm bg-white/90 p-6 rounded-xl shadow-sm border border-slate-200/60 hover:shadow-lg transition-all duration-200 hover:scale-[1.02] active:scale-95 group"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-gradient-to-br from-violet-100 to-violet-200 rounded-lg mr-4 group-hover:from-violet-200 group-hover:to-violet-300 transition-all duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900">View Reports</h3>
|
||||
<p class="text-slate-600 text-sm">Analytics and insights</p>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-else class="flex justify-center items-center h-64">
|
||||
<div class="text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-4 text-slate-600">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom styles */
|
||||
</style>
|
||||
369
old_maps/frontend_old/Archive/src/views/Debug.vue
Normal file
369
old_maps/frontend_old/Archive/src/views/Debug.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="debug-page">
|
||||
<h1>🚨 DEBUG VIEW - RAW STATE INSPECTOR</h1>
|
||||
|
||||
<div class="credentials">
|
||||
<h2>Test Credentials</h2>
|
||||
<p><strong>Email:</strong> tester_pro@profibot.hu</p>
|
||||
<p><strong>Password:</strong> Password123!</p>
|
||||
<p class="note">Use these credentials to test the login flow</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<h2>Auth Store Actions</h2>
|
||||
<div class="button-group">
|
||||
<button @click="triggerLogin" :disabled="false">
|
||||
🔐 Trigger Login (tester_pro@profibot.hu)
|
||||
</button>
|
||||
|
||||
<button @click="fetchUserProfile" :disabled="!authStore.isLoggedIn">
|
||||
👤 Fetch User Profile
|
||||
</button>
|
||||
|
||||
<button @click="fetchVehicles" :disabled="!authStore.isLoggedIn">
|
||||
🚗 Fetch Vehicles (Garage)
|
||||
</button>
|
||||
|
||||
<button @click="clearState">
|
||||
🗑️ Clear All State
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<p><strong>Status:</strong> {{ statusMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Auth Store State -->
|
||||
<div class="panel">
|
||||
<h2>🔐 Auth Store State</h2>
|
||||
<div class="state-info">
|
||||
<p><span class="label">isLoggedIn:</span> <span :class="authStore.isLoggedIn ? 'yes' : 'no'">{{ authStore.isLoggedIn ? 'YES' : 'NO' }}</span></p>
|
||||
<p><span class="label">isAdmin:</span> <span :class="authStore.isAdmin ? 'yes' : 'no'">{{ authStore.isAdmin ? 'YES' : 'NO' }}</span></p>
|
||||
<p><span class="label">isTester:</span> <span :class="authStore.isTester ? 'yes' : 'no'">{{ authStore.isTester ? 'YES' : 'NO' }}</span></p>
|
||||
</div>
|
||||
|
||||
<h3>Raw JSON State:</h3>
|
||||
<pre>{{ authStoreJSON }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Garage Store State -->
|
||||
<div class="panel">
|
||||
<h2>🚗 Garage Store State</h2>
|
||||
<div class="state-info">
|
||||
<p><span class="label">Vehicles Count:</span> {{ garageStore.vehicles ? garageStore.vehicles.length : 0 }}</p>
|
||||
<p><span class="label">Loading:</span> {{ garageStore.loading ? 'YES' : 'NO' }}</p>
|
||||
</div>
|
||||
|
||||
<h3>Raw JSON State:</h3>
|
||||
<pre>{{ garageStoreJSON }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LocalStorage Inspection -->
|
||||
<div class="panel">
|
||||
<h2>💾 LocalStorage Inspection</h2>
|
||||
<div class="storage-grid">
|
||||
<div class="storage-item">
|
||||
<div class="storage-label">token</div>
|
||||
<div class="storage-value">{{ localStorage.token ? `${localStorage.token.substring(0, 30)}...` : '(empty)' }}</div>
|
||||
</div>
|
||||
<div class="storage-item">
|
||||
<div class="storage-label">user_email</div>
|
||||
<div class="storage-value">{{ localStorage.user_email || '(empty)' }}</div>
|
||||
</div>
|
||||
<div class="storage-item">
|
||||
<div class="storage-label">is_admin</div>
|
||||
<div class="storage-value">{{ localStorage.is_admin || '(empty)' }}</div>
|
||||
</div>
|
||||
<div class="storage-item">
|
||||
<div class="storage-label">user_role</div>
|
||||
<div class="storage-value">{{ localStorage.user_role || '(empty)' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="refreshLocalStorage">
|
||||
🔄 Refresh LocalStorage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useGarageStore } from '@/stores/garageStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const garageStore = useGarageStore()
|
||||
|
||||
const statusMessage = ref('Ready')
|
||||
const localStorage = ref({})
|
||||
|
||||
// Computed properties for JSON display
|
||||
const authStoreJSON = computed(() => {
|
||||
return JSON.stringify({
|
||||
token: authStore.token,
|
||||
isLoggedIn: authStore.isLoggedIn,
|
||||
isAdmin: authStore.isAdmin,
|
||||
isTester: authStore.isTester,
|
||||
userEmail: authStore.userEmail,
|
||||
userRole: authStore.userRole,
|
||||
userProfile: authStore.userProfile,
|
||||
displayName: authStore.displayName,
|
||||
activeOrgId: authStore.activeOrgId
|
||||
}, null, 2)
|
||||
})
|
||||
|
||||
const garageStoreJSON = computed(() => {
|
||||
return JSON.stringify({
|
||||
vehicles: garageStore.vehicles,
|
||||
loading: garageStore.loading,
|
||||
error: garageStore.error,
|
||||
selectedVehicle: garageStore.selectedVehicle
|
||||
}, null, 2)
|
||||
})
|
||||
|
||||
// Functions
|
||||
const triggerLogin = async () => {
|
||||
statusMessage.value = 'Logging in with tester_pro@profibot.hu...'
|
||||
try {
|
||||
await authStore.login('tester_pro@profibot.hu', 'Password123!')
|
||||
statusMessage.value = 'Login successful! Check auth store state.'
|
||||
refreshLocalStorage()
|
||||
} catch (error) {
|
||||
statusMessage.value = `Login failed: ${error.message}`
|
||||
console.error('Login error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
statusMessage.value = 'Fetching user profile...'
|
||||
try {
|
||||
await authStore.fetchUserProfile()
|
||||
statusMessage.value = 'User profile fetched successfully!'
|
||||
} catch (error) {
|
||||
statusMessage.value = `Failed to fetch user profile: ${error.message}`
|
||||
console.error('Fetch user profile error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVehicles = async () => {
|
||||
statusMessage.value = 'Fetching vehicles...'
|
||||
try {
|
||||
await garageStore.fetchVehicles()
|
||||
statusMessage.value = `Vehicles fetched successfully! Found ${garageStore.vehicles?.length || 0} vehicles.`
|
||||
} catch (error) {
|
||||
statusMessage.value = `Failed to fetch vehicles: ${error.message}`
|
||||
console.error('Fetch vehicles error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const clearState = () => {
|
||||
authStore.logout()
|
||||
garageStore.$reset()
|
||||
refreshLocalStorage()
|
||||
statusMessage.value = 'All state cleared!'
|
||||
}
|
||||
|
||||
const refreshLocalStorage = () => {
|
||||
localStorage.value = {
|
||||
token: window.localStorage.getItem('token') || '',
|
||||
user_email: window.localStorage.getItem('user_email') || '',
|
||||
is_admin: window.localStorage.getItem('is_admin') || '',
|
||||
user_role: window.localStorage.getItem('user_role') || '',
|
||||
refresh_token: window.localStorage.getItem('refresh_token') ? '***' : '',
|
||||
ui_mode: window.localStorage.getItem('ui_mode') || '',
|
||||
active_org_id: window.localStorage.getItem('active_org_id') || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
refreshLocalStorage()
|
||||
statusMessage.value = 'Debug view loaded. Use buttons to test auth flow.'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.debug-page {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
font-family: monospace;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
border-bottom: 2px solid #1e3a8a;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin: 15px 0 10px 0;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 10px 0 5px 0;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
}
|
||||
|
||||
.credentials {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.credentials h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 15px;
|
||||
background: #1e3a8a; /* sötétkék */
|
||||
color: white;
|
||||
border: 1px solid #1e3a8a;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1e40af; /* sötétkék - sötétebb */
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
color: #666;
|
||||
border-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
margin-top: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status p {
|
||||
margin: 0;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.state-info {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.state-info p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: bold;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.yes {
|
||||
color: #065f46; /* zöld */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.no {
|
||||
color: #991b1b; /* piros */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #1e1e1e;
|
||||
color: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.storage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.storage-item {
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.storage-label {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.storage-value {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
color: black;
|
||||
}
|
||||
</style>
|
||||
8
old_maps/frontend_old/Archive/src/views/Expenses.vue
Executable file
8
old_maps/frontend_old/Archive/src/views/Expenses.vue
Executable file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800">Költségek kezelése</h2>
|
||||
<div class="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<p>Itt tudod majd rögzíteni és listázni a kiadásaidat.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
33
old_maps/frontend_old/Archive/src/views/ForgotPassword.vue
Executable file
33
old_maps/frontend_old/Archive/src/views/ForgotPassword.vue
Executable file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto mt-20 p-8 bg-white rounded-2xl shadow-xl border">
|
||||
<h2 class="text-2xl font-bold text-center mb-4 text-gray-800">Elfelejtett jelszó</h2>
|
||||
<p class="text-sm text-gray-600 mb-6 text-center">Add meg az e-mail címed, és küldünk egy linket a jelszó visszaállításához.</p>
|
||||
|
||||
<form @submit.prevent="handleForgot" class="space-y-4">
|
||||
<input v-model="email" type="email" placeholder="E-mail címed" required class="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500" />
|
||||
<button type="submit" class="w-full bg-blue-700 text-white py-3 rounded-lg font-bold hover:bg-blue-800">Küldés</button>
|
||||
</form>
|
||||
|
||||
<p v-if="message" class="mt-4 text-center font-medium text-blue-600">{{ message }}</p>
|
||||
<div class="mt-6 text-center">
|
||||
<router-link to="/login" class="text-sm text-gray-500 hover:underline">Vissza a belépéshez</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const email = ref('')
|
||||
const message = ref('')
|
||||
|
||||
const handleForgot = async () => {
|
||||
try {
|
||||
await axios.post(`http://192.168.100.43:8000/api/v2/auth/forgot-password?email=${email.value}`)
|
||||
message.value = "Ha a cím létezik, a helyreállító levelet elküldtük."
|
||||
} catch (err) {
|
||||
message.value = "Hiba történt a kérés feldolgozásakor."
|
||||
}
|
||||
}
|
||||
</script>
|
||||
204
old_maps/frontend_old/Archive/src/views/Login.vue
Executable file
204
old_maps/frontend_old/Archive/src/views/Login.vue
Executable file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-gray-100 p-4">
|
||||
<div class="max-w-md w-full mx-auto">
|
||||
<!-- Logo & Header -->
|
||||
<div class="text-center mb-10">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-r from-blue-600 to-blue-800 rounded-3xl shadow-2xl mb-6">
|
||||
<span class="text-4xl">🚗</span>
|
||||
</div>
|
||||
<h1 class="text-4xl font-black text-gray-900 mb-2 tracking-tight">Service Finder</h1>
|
||||
<p class="text-gray-500 font-medium">Smart Garage Management System</p>
|
||||
<div class="mt-4 inline-flex items-center gap-2 bg-blue-100 text-blue-700 px-4 py-2 rounded-full text-sm font-bold">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
V2.0 • Production Ready
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Card -->
|
||||
<div class="bg-white rounded-3xl shadow-2xl border border-gray-200 p-8 md:p-10">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-black text-gray-900 mb-2">Login to your account</h2>
|
||||
<p class="text-gray-500 text-sm">Enter your email and password to continue</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<!-- Email Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="email" class="text-sm font-bold text-gray-700 uppercase tracking-wide">Email address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="w-full pl-12 pr-4 py-4 bg-gray-50 border-2 border-transparent rounded-2xl focus:border-blue-500 focus:bg-white transition-all outline-none text-gray-900 placeholder-gray-400"
|
||||
placeholder="superadmin@profibot.hu"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">For testing use: superadmin@profibot.hu</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<label for="password" class="text-sm font-bold text-gray-700 uppercase tracking-wide">Password</label>
|
||||
<router-link to="/forgot-password" class="text-xs font-bold text-blue-600 hover:text-blue-800 transition">
|
||||
Forgot password?
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
class="w-full pl-12 pr-12 py-4 bg-gray-50 border-2 border-transparent rounded-2xl focus:border-blue-500 focus:bg-white transition-all outline-none text-gray-900 placeholder-gray-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-blue-600 transition"
|
||||
>
|
||||
<svg v-if="showPassword" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L6.59 6.59m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">Any password works for mock login</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="p-4 bg-red-50 border-l-4 border-red-500 text-red-700 rounded-xl text-sm font-bold animate-pulse">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white py-4 rounded-2xl font-black text-lg hover:from-blue-700 hover:to-blue-800 transition-all shadow-xl hover:shadow-2xl hover:shadow-blue-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ loading ? 'LOGGING IN...' : 'LOGIN' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-8 flex items-center">
|
||||
<div class="flex-grow border-t border-gray-200"></div>
|
||||
<span class="mx-4 text-gray-400 text-sm font-medium">VAGY</span>
|
||||
<div class="flex-grow border-t border-gray-200"></div>
|
||||
</div>
|
||||
|
||||
<!-- Register Link -->
|
||||
<div class="text-center">
|
||||
<p class="text-gray-500 font-medium mb-4">Nincs még fiókod?</p>
|
||||
<router-link
|
||||
to="/register"
|
||||
class="inline-block w-full py-3 px-6 border-2 border-blue-600 text-blue-600 rounded-2xl font-bold hover:bg-blue-50 transition-all hover:border-blue-700 hover:text-blue-700"
|
||||
>
|
||||
Új Széf létrehozása
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Demo Info -->
|
||||
<div class="mt-8 p-4 bg-blue-50 rounded-2xl border border-blue-100">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-700">
|
||||
<p class="font-bold mb-1">Demo módban vagy</p>
|
||||
<p>A bejelentkezés mockolt, automatikusan sikeres lesz. A rendszer a <span class="font-mono bg-blue-100 px-2 py-0.5 rounded">/profile-select</span> oldalra irányít, ahol kiválaszthatod a felület módot.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-8 text-center text-gray-400 text-sm">
|
||||
<p>© 2026 Service Finder • Smart Garage Management • v2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const email = ref('superadmin@profibot.hu')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
console.log('Login: Starting login process for', email.value)
|
||||
|
||||
try {
|
||||
// Use the auth store for login
|
||||
console.log('Login: Calling authStore.login()')
|
||||
await authStore.login(email.value, password.value)
|
||||
|
||||
console.log('Login: authStore.login() completed successfully')
|
||||
// The auth store will handle the redirect to /profile-select
|
||||
// No need to do anything else here
|
||||
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
error.value = err.message || 'Hiba történt a bejelentkezés során'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom scrollbar for the page */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3b82f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-blue-50 p-4">
|
||||
<ProfileSelector />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProfileSelector from '@/components/ProfileSelector.vue'
|
||||
</script>
|
||||
141
old_maps/frontend_old/Archive/src/views/Register.vue
Executable file
141
old_maps/frontend_old/Archive/src/views/Register.vue
Executable file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto mt-10 p-8 bg-white rounded-2xl shadow-xl">
|
||||
<h2 class="text-2xl font-bold mb-6 text-center">Új Széf létrehozása</h2>
|
||||
|
||||
<!-- Registration Form (shown when not successful) -->
|
||||
<div v-if="!success">
|
||||
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||
<input v-model="form.first_name" type="text" placeholder="Keresztnév" required class="w-full p-3 border rounded-lg" />
|
||||
<input v-model="form.last_name" type="text" placeholder="Vezetéknév" required class="w-full p-3 border rounded-lg" />
|
||||
<input v-model="form.email" type="email" placeholder="E-mail" required class="w-full p-3 border rounded-lg" />
|
||||
<input v-model="form.password" type="password" placeholder="Jelszó" required class="w-full p-3 border rounded-lg" />
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="w-full bg-green-600 text-white py-3 rounded-lg font-bold hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="loading">Feldolgozás...</span>
|
||||
<span v-else>Fiók létrehozása</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="msg && !success" :class="['mt-4 text-center font-medium', isError ? 'text-red-600' : 'text-green-600']">{{ msg }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Card (shown after successful registration) -->
|
||||
<div v-else class="text-center">
|
||||
<div class="mb-6">
|
||||
<!-- Animated checkmark -->
|
||||
<div class="relative inline-flex items-center justify-center w-24 h-24">
|
||||
<div class="absolute inset-0 bg-green-100 rounded-full"></div>
|
||||
<svg class="relative w-16 h-16 text-green-600 animate-checkmark" viewBox="0 0 52 52">
|
||||
<circle class="stroke-current" cx="26" cy="26" r="25" fill="none" stroke-width="2"/>
|
||||
<path class="stroke-current" fill="none" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-3">Sikeres regisztráció!</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Elküldtünk egy megerősítő e-mailt a(z) <span class="font-semibold">{{ form.email }}</span> címre.
|
||||
Kérjük, ellenőrizd a postaládádat és kattints a linkre a fiók aktiválásához.
|
||||
</p>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 text-left">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Fontos:</strong> A megerősítő e-mail néhány perc múlva érkezhet meg.
|
||||
Ha nem találod, ellenőrizd a spam mappát is.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="resetForm"
|
||||
class="w-full bg-gray-200 text-gray-800 py-3 rounded-lg font-bold hover:bg-gray-300 transition"
|
||||
>
|
||||
Új regisztráció
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const form = ref({ email: '', password: '', first_name: '', last_name: '' })
|
||||
const msg = ref('')
|
||||
const isError = ref(false)
|
||||
const loading = ref(false)
|
||||
const success = ref(false)
|
||||
|
||||
const handleRegister = async () => {
|
||||
msg.value = ""; // Üzenet alaphelyzetbe
|
||||
isError.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.post('/auth/register', form.value);
|
||||
// Sikeres válasz: 201 Created
|
||||
success.value = true
|
||||
msg.value = `Verification email sent to ${form.value.email}!`;
|
||||
isError.value = false
|
||||
} catch (err) {
|
||||
isError.value = true
|
||||
success.value = false
|
||||
// ITT A JAVÍTÁS: kiolvassuk a backend hibaüzenetét
|
||||
if (err.response && err.response.data && err.response.data.detail) {
|
||||
msg.value = "Hiba: " + err.response.data.detail;
|
||||
} else {
|
||||
msg.value = "Hiba történt a regisztráció során.";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
success.value = false
|
||||
form.value = { email: '', password: '', first_name: '', last_name: '' }
|
||||
msg.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes checkmark {
|
||||
0% {
|
||||
stroke-dashoffset: 100;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-checkmark circle {
|
||||
stroke-dasharray: 166;
|
||||
stroke-dashoffset: 166;
|
||||
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-checkmark path {
|
||||
stroke-dasharray: 48;
|
||||
stroke-dashoffset: 48;
|
||||
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
|
||||
}
|
||||
|
||||
@keyframes stroke {
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
old_maps/frontend_old/Archive/src/views/ResetPassword.vue
Executable file
43
old_maps/frontend_old/Archive/src/views/ResetPassword.vue
Executable file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto mt-20 p-8 bg-white rounded-2xl shadow-xl border">
|
||||
<h2 class="text-2xl font-bold text-center mb-6">Új jelszó megadása</h2>
|
||||
|
||||
<form @submit.prevent="handleReset" class="space-y-4">
|
||||
<input v-model="password" type="password" placeholder="Új jelszó" required class="w-full p-3 border rounded-lg" />
|
||||
<input v-model="passwordConfirm" type="password" placeholder="Jelszó megerősítése" required class="w-full p-3 border rounded-lg" />
|
||||
<button type="submit" class="w-full bg-blue-700 text-white py-3 rounded-lg font-bold">Jelszó mentése</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="mt-4 text-red-600 text-center">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const password = ref('')
|
||||
const passwordConfirm = ref('')
|
||||
const error = ref('')
|
||||
|
||||
const handleReset = async () => {
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
error.value = "A két jelszó nem egyezik!";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = route.query.token;
|
||||
await axios.post(`http://192.168.100.43:8000/api/v2/auth/reset-password-confirm`, {
|
||||
token: token,
|
||||
new_password: password.value
|
||||
});
|
||||
alert("Jelszó sikeresen megváltoztatva!");
|
||||
router.push('/login');
|
||||
} catch (err) {
|
||||
error.value = "Hiba történt. Lehetséges, hogy a link lejárt.";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
48
old_maps/frontend_old/Archive/src/views/admin/AdminStats.vue
Executable file
48
old_maps/frontend_old/Archive/src/views/admin/AdminStats.vue
Executable file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Rendszer Statisztika (Admin)</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-blue-600 text-white p-6 rounded-lg shadow-lg">
|
||||
<p class="text-blue-100 uppercase text-xs font-bold">Összes Felhasználó</p>
|
||||
<p class="text-4xl font-black">1,248</p>
|
||||
</div>
|
||||
<div class="bg-emerald-600 text-white p-6 rounded-lg shadow-lg">
|
||||
<p class="text-emerald-100 uppercase text-xs font-bold">Aktív Voucherek</p>
|
||||
<p class="text-4xl font-black">42</p>
|
||||
</div>
|
||||
<div class="bg-amber-600 text-white p-6 rounded-lg shadow-lg">
|
||||
<p class="text-amber-100 uppercase text-xs font-bold">Bot Találatok (Új)</p>
|
||||
<p class="text-4xl font-black">156</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="p-4 border-b bg-gray-50 flex justify-between items-center">
|
||||
<span class="font-bold text-gray-700">Jóváhagyásra váró szervizek</span>
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">Összes frissítése</button>
|
||||
</div>
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead class="bg-gray-100 text-gray-600 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="p-3">Név</th>
|
||||
<th class="p-3">Város</th>
|
||||
<th class="p-3">Típus</th>
|
||||
<th class="p-3 text-right">Művelet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 text-sm">
|
||||
<tr v-for="i in 3" :key="i" class="hover:bg-gray-50">
|
||||
<td class="p-3 font-semibold">Profi Gumiszerviz #{{i}}</td>
|
||||
<td class="p-3">Budapest</td>
|
||||
<td class="p-3"><span class="bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs">Gumis</span></td>
|
||||
<td class="p-3 text-right">
|
||||
<button class="text-blue-600 hover:underline mr-3">Szerkeszt</button>
|
||||
<button class="text-green-600 hover:underline font-bold text-lg">✓</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user