379 lines
14 KiB
Vue
379 lines
14 KiB
Vue
<template>
|
||
<div class="relative min-h-screen text-white">
|
||
<!-- Garage Background Image -->
|
||
<div class="fixed inset-0 z-0">
|
||
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
|
||
</div>
|
||
|
||
<!-- ── Zen Mode FAB ── -->
|
||
<button
|
||
@click="isUiVisible = !isUiVisible"
|
||
class="fixed bottom-8 right-8 z-50 btn-premium rounded-full p-4 shadow-2xl shadow-black/40"
|
||
:title="isUiVisible ? t('zen.hide') : t('zen.show')"
|
||
>
|
||
<svg
|
||
v-if="isUiVisible"
|
||
class="w-6 h-6"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
<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" />
|
||
</svg>
|
||
<svg
|
||
v-else
|
||
class="w-6 h-6"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<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.88 9.88l-3.29-3.29m7.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" />
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- ── Content with fade transition ── -->
|
||
<Transition name="fade">
|
||
<div v-if="isUiVisible" class="relative z-10">
|
||
<!-- ── Bottom-aligned 5-card container ── -->
|
||
<div class="flex flex-col justify-end min-h-[85vh] pb-8">
|
||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
|
||
<!-- ── Loading State ── -->
|
||
<div
|
||
v-if="vehicleStore.isLoading"
|
||
class="flex items-center justify-center py-20"
|
||
>
|
||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]" />
|
||
</div>
|
||
|
||
<!-- ── 5-Card Grid ── -->
|
||
<div
|
||
v-if="!vehicleStore.isLoading"
|
||
class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
|
||
style="perspective: 1200px;"
|
||
>
|
||
<!-- Card 1: 🚗 My Garage (Járműveim) -->
|
||
<MyVehiclesCard
|
||
@open-card="openCard"
|
||
@open-vehicle-detail="openVehicleDetail"
|
||
/>
|
||
|
||
<!-- Card 2: 💰 Costs & Quick Actions (Action Center) -->
|
||
<CostsActionsCard @cost-saved="onCostSaved" />
|
||
|
||
<!-- Card 3: 🔧 Service Finder (3-Way Indítópult) -->
|
||
<ServiceFinderCard />
|
||
|
||
<!-- Card 4: 🏆 Gamification (Játékosítás) -->
|
||
<GamificationCard
|
||
@open-card="openCard"
|
||
/>
|
||
|
||
<!-- Card 5: 🛡️ My Profile & Trust (Profil & Trust Score) -->
|
||
<ProfileTrustCard
|
||
@open-card="openCard"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div><!-- end relative z-10 content wrapper -->
|
||
</Transition>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════
|
||
Flip Modal – 3D átforduló részletes nézet (Teleport to body)
|
||
═══════════════════════════════════════════════════════════════════ -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="activeCard"
|
||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
|
||
@click.self="activeCard = null"
|
||
>
|
||
<div class="bg-white w-11/12 max-w-5xl h-auto min-h-[500px] pb-6 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
|
||
<!-- Close button (X) -->
|
||
<button
|
||
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
|
||
@click="activeCard = null"
|
||
>
|
||
✕
|
||
</button>
|
||
|
||
<!-- Dynamic content area -->
|
||
<div class="flex-1 p-8 overflow-y-auto text-slate-800">
|
||
<!-- ── Private Vehicle Manager ── -->
|
||
<PrivateVehicleManager
|
||
v-if="activeCard === 'vehicles'"
|
||
:target-vehicle-id="selectedTargetId"
|
||
@add-new="handleAddNew"
|
||
@edit-vehicle="handleEditVehicle"
|
||
/>
|
||
|
||
<!-- ── Fallback for other cards ── -->
|
||
<template v-if="activeCard && activeCard !== 'vehicles'">
|
||
<h2 class="text-2xl font-bold mb-6">
|
||
{{ t('dashboard.detailedView') }}: <span class="text-slate-500">{{ activeCard }}</span>
|
||
</h2>
|
||
<p class="text-slate-500">
|
||
{{ t('dashboard.modalPlaceholder') }}
|
||
</p>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════
|
||
Vehicle Detail Modal (Digital Twin — 3-Tab Deep Link)
|
||
═══════════════════════════════════════════════════════════════════ -->
|
||
<VehicleDetailModal
|
||
:is-open="isVehicleDetailOpen"
|
||
:vehicle="detailVehicle"
|
||
:refresh-trigger="refreshCostsTrigger"
|
||
@close="closeVehicleDetail"
|
||
@edit="handleEditFromDetail"
|
||
@set-primary="handleSetPrimaryFromDetail"
|
||
/>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════
|
||
Vehicle Form Modal (Add) — standalone, decoupled from Manager
|
||
═══════════════════════════════════════════════════════════════════ -->
|
||
<VehicleFormModal
|
||
:is-open="isAddFormOpen"
|
||
@close="isAddFormOpen = false"
|
||
@saved="onAddVehicleSaved"
|
||
/>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════
|
||
Vehicle Form Modal (Edit) — standalone, decoupled from Manager
|
||
═══════════════════════════════════════════════════════════════════ -->
|
||
<VehicleFormModal
|
||
:is-open="isEditFormOpen"
|
||
:vehicle="editTargetVehicle"
|
||
@close="closeEditForm"
|
||
@saved="onEditVehicleSaved"
|
||
@deleted="onVehicleDeleted"
|
||
/>
|
||
</div><!-- end min-h-screen -->
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { useVehicleStore } from '../stores/vehicle'
|
||
import type { VehicleData } from '../types/vehicle'
|
||
import api from '../api/axios'
|
||
|
||
// ── Dashboard Card Components ──
|
||
import MyVehiclesCard from '../components/dashboard/MyVehiclesCard.vue'
|
||
import CostsActionsCard from '../components/dashboard/CostsActionsCard.vue'
|
||
import ServiceFinderCard from '../components/dashboard/ServiceFinderCard.vue'
|
||
import GamificationCard from '../components/dashboard/GamificationCard.vue'
|
||
import ProfileTrustCard from '../components/dashboard/ProfileTrustCard.vue'
|
||
|
||
// ── Shared Modals ──
|
||
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
|
||
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
|
||
import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue'
|
||
|
||
const { t } = useI18n()
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
|
||
const authStore = useAuthStore()
|
||
const vehicleStore = useVehicleStore()
|
||
|
||
// ── Zen Mode ──
|
||
const isUiVisible = ref(true)
|
||
|
||
// ── Flip Modal State ──
|
||
const activeCard = ref<string | null>(null)
|
||
const selectedTargetId = ref<string | null>(null)
|
||
const openCard = (cardId: string, targetId: string | null = null) => {
|
||
activeCard.value = cardId
|
||
selectedTargetId.value = targetId
|
||
}
|
||
|
||
// ── Vehicle Detail Modal (Deep Link from plate click) ──
|
||
const isVehicleDetailOpen = ref(false)
|
||
const detailVehicle = ref<VehicleData | null>(null)
|
||
|
||
/**
|
||
* F5 Bug fix (RBAC Phase 3): Számláló, ami minden költség mentéskor nő.
|
||
* A VehicleDetailModal ezt figyelve újratölti a költségeket.
|
||
*/
|
||
const refreshCostsTrigger = ref(0)
|
||
|
||
function openVehicleDetail(vehicle: VehicleData) {
|
||
detailVehicle.value = vehicle
|
||
isVehicleDetailOpen.value = true
|
||
}
|
||
|
||
function closeVehicleDetail() {
|
||
isVehicleDetailOpen.value = false
|
||
detailVehicle.value = null
|
||
}
|
||
|
||
/**
|
||
* F5 Bug fix (RBAC Phase 3): Kezeli a CostsActionsCard cost-saved eseményét.
|
||
* Növeli a refreshCostsTrigger számlálót, ami a VehicleDetailModal-ban
|
||
* watch által figyelve újratölti a költségeket.
|
||
*/
|
||
function onCostSaved(vehicleId: string) {
|
||
refreshCostsTrigger.value++
|
||
}
|
||
|
||
// ── Vehicle Form Modal State (standalone, decoupled from Manager) ──
|
||
const isAddFormOpen = ref(false)
|
||
const isEditFormOpen = ref(false)
|
||
const editTargetVehicle = ref<VehicleData | null>(null)
|
||
const isCheckingQuota = ref(false)
|
||
|
||
/**
|
||
* Return-State flag: when true, closing the edit form should re-open
|
||
* the VehicleDetailModal instead of returning to the dashboard.
|
||
*/
|
||
const returnToVehicleDetail = ref(false)
|
||
|
||
/**
|
||
* Preemptive quota check before opening the add-vehicle modal.
|
||
* Calls GET /api/v1/assets/vehicles/quota-status.
|
||
* If the user has reached their limit, shows a blocking toast/alert instead of opening the form.
|
||
*/
|
||
async function handleAddNew() {
|
||
if (isCheckingQuota.value) return
|
||
isCheckingQuota.value = true
|
||
|
||
try {
|
||
const res = await api.get('/assets/vehicles/quota-status')
|
||
const { can_add, current_count, limit } = res.data
|
||
|
||
if (!can_add) {
|
||
alert(
|
||
`Elérted a csomagodhoz tartozó járműlimitet (${current_count}/${limit})! ` +
|
||
'Kérjük, válts magasabb csomagra az új jármű rögzítéséhez.'
|
||
)
|
||
return
|
||
}
|
||
|
||
isAddFormOpen.value = true
|
||
} catch (err: any) {
|
||
console.error('[DashboardView] Quota check failed:', err)
|
||
isAddFormOpen.value = true
|
||
} finally {
|
||
isCheckingQuota.value = false
|
||
}
|
||
}
|
||
|
||
function onAddVehicleSaved() {
|
||
isAddFormOpen.value = false
|
||
}
|
||
|
||
function handleEditVehicle(vehicle: VehicleData) {
|
||
editTargetVehicle.value = vehicle ? { ...vehicle } : null
|
||
isEditFormOpen.value = true
|
||
}
|
||
|
||
function closeEditForm() {
|
||
isEditFormOpen.value = false
|
||
// If we came from the detail modal, return to it
|
||
if (returnToVehicleDetail.value) {
|
||
returnToVehicleDetail.value = false
|
||
const vehicle = editTargetVehicle.value
|
||
editTargetVehicle.value = null
|
||
if (vehicle) {
|
||
nextTick(() => {
|
||
detailVehicle.value = vehicle
|
||
isVehicleDetailOpen.value = true
|
||
})
|
||
}
|
||
} else {
|
||
editTargetVehicle.value = null
|
||
}
|
||
}
|
||
|
||
function onEditVehicleSaved(savedVehicle?: VehicleData) {
|
||
isEditFormOpen.value = false
|
||
// If we came from the detail modal, return to it
|
||
if (returnToVehicleDetail.value) {
|
||
returnToVehicleDetail.value = false
|
||
// Use the saved vehicle (with updated data) if available, otherwise fallback
|
||
const vehicle = savedVehicle || editTargetVehicle.value
|
||
editTargetVehicle.value = null
|
||
if (vehicle) {
|
||
nextTick(() => {
|
||
detailVehicle.value = vehicle
|
||
isVehicleDetailOpen.value = true
|
||
})
|
||
}
|
||
} else {
|
||
editTargetVehicle.value = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Called when the edit VehicleFormModal emits 'deleted'.
|
||
* Completely resets the return-state and closes the edit form,
|
||
* then refreshes the vehicle list so the deleted vehicle disappears.
|
||
*/
|
||
function onVehicleDeleted() {
|
||
returnToVehicleDetail.value = false
|
||
isEditFormOpen.value = false
|
||
editTargetVehicle.value = null
|
||
vehicleStore.fetchVehicles()
|
||
}
|
||
|
||
// ── Bridge: Detail → Edit (opens edit modal directly, no Manager card) ──
|
||
function handleEditFromDetail(vehicle: VehicleData) {
|
||
returnToVehicleDetail.value = true
|
||
closeVehicleDetail()
|
||
// Use nextTick so detail modal closes before edit opens
|
||
nextTick(() => {
|
||
editTargetVehicle.value = vehicle ? { ...vehicle } : null
|
||
isEditFormOpen.value = true
|
||
})
|
||
}
|
||
|
||
function handleSetPrimaryFromDetail(vehicle: VehicleData) {
|
||
vehicleStore.setPrimaryVehicle(vehicle.id)
|
||
}
|
||
|
||
onMounted(() => {
|
||
vehicleStore.fetchVehicles()
|
||
authStore.fetchMyOrganizations()
|
||
|
||
// ── Handle query param card navigation ──────────────────────────
|
||
const cardParam = route.query.card as string | undefined
|
||
if (cardParam) {
|
||
openCard(cardParam)
|
||
// Clean up the query param so it doesn't re-trigger
|
||
router.replace({ query: {} })
|
||
}
|
||
})
|
||
|
||
// ── Watch for query param changes (hamburger menu navigation) ────
|
||
watch(() => route.query.card, (newCard) => {
|
||
if (newCard && typeof newCard === 'string') {
|
||
openCard(newCard)
|
||
router.replace({ query: {} })
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
@keyframes flipIn {
|
||
from {
|
||
transform: perspective(1200px) rotateY(-90deg);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: perspective(1200px) rotateY(0deg);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
.animate-flip-in {
|
||
animation: flipIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
|
||
}
|
||
</style>
|