2026.06.04 frontend építés közben

This commit is contained in:
Roo
2026-06-04 07:26:22 +00:00
parent 7adf6cc3e3
commit 59a30ac428
3302 changed files with 24091 additions and 1771 deletions

View 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>

View 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

View 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">
&times;
</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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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')

View 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;

View 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
}
}

View 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,
}
})

View 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,
}
})

View 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
}
})

View 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,
}
})

View 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
}
})

View 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
}
})

View 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', 'Lambdaszonda', 'Féktárcsa', 'Olajszűrő'],
correctAnswer: 1,
explanation: 'A lambdaszonda 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 ólomsavas 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
}
})

View 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
})

View 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);
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>