RABC fejlesztése és beélesítése hibák kijavításával
This commit is contained in:
@@ -265,7 +265,8 @@ async function handleSubmit() {
|
||||
asset_id: props.vehicle.id,
|
||||
// organization_id is omitted — backend auto-resolves from asset
|
||||
category_id: categoryId,
|
||||
amount_net: form.amount,
|
||||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||
amount_gross: form.amount,
|
||||
currency: 'HUF' as const,
|
||||
date: new Date(form.date).toISOString(),
|
||||
mileage_at_cost: null,
|
||||
|
||||
171
frontend/src/components/dashboard/CostsActionsCard.vue
Normal file
171
frontend/src/components/dashboard/CostsActionsCard.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2">
|
||||
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<!-- Vehicle Selector Dropdown -->
|
||||
<div class="mb-3">
|
||||
<label class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1 block">{{ t('dashboard.vehicle') }}</label>
|
||||
<select
|
||||
v-model="selectedActionVehicleId"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
>
|
||||
<option
|
||||
v-for="v in vehicleStore.sortedVehicles"
|
||||
:key="v.id"
|
||||
:value="v.id"
|
||||
>
|
||||
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex-1 flex flex-col gap-2.5 justify-center">
|
||||
<!-- No vehicle warning -->
|
||||
<div
|
||||
v-if="!selectedActionVehicle"
|
||||
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
<span>⚠️ {{ t('dashboard.noVehicleSelected') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ⛽ Record Fueling (Big, prominent) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openFuelModal"
|
||||
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg">⛽</span>
|
||||
<span>{{ t('dashboard.recordFueling') }}</span>
|
||||
<span class="ml-auto text-white/60 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<!-- 🅿️ Parking / Toll (Medium) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openFeeModal"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🅿️</span>
|
||||
<span>{{ t('dashboard.parkingToll') }}</span>
|
||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<!-- ➕ Additional Costs (Medium) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openComplexModal"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">➕</span>
|
||||
<span>{{ t('dashboard.additionalCosts') }}</span>
|
||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Cost Action Modals (Teleported to body)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<SimpleFuelModal
|
||||
:is-open="isFuelModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
@close="isFuelModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
<ComplexExpenseModal
|
||||
:is-open="isFeeModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
:is-fee-mode="true"
|
||||
@close="isFeeModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
<ComplexExpenseModal
|
||||
:is-open="isComplexModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
:is-fee-mode="false"
|
||||
@close="isComplexModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import SimpleFuelModal from './SimpleFuelModal.vue'
|
||||
import ComplexExpenseModal from './ComplexExpenseModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* F5 Bug fix (RBAC Phase 3): Értesíti a szülő komponenst, hogy egy költség sikeresen el lett mentve.
|
||||
* A szülő (DashboardView) továbbítja ezt a VehicleDetailModal-nak, ami újratölti a költségeket.
|
||||
*/
|
||||
'cost-saved': [vehicleId: string]
|
||||
}>()
|
||||
|
||||
// ── Cost Card Action Center State ──
|
||||
const selectedActionVehicleId = ref<string>('')
|
||||
const isFuelModalOpen = ref(false)
|
||||
const isFeeModalOpen = ref(false)
|
||||
const isComplexModalOpen = ref(false)
|
||||
|
||||
/** The currently selected vehicle object for the action modals */
|
||||
const selectedActionVehicle = computed(() => {
|
||||
if (!selectedActionVehicleId.value) return null
|
||||
return vehicleStore.vehicles.find(v => v.id === selectedActionVehicleId.value) || null
|
||||
})
|
||||
|
||||
/** Auto-select the first (primary/sorted) vehicle on load */
|
||||
function autoSelectVehicle() {
|
||||
if (vehicleStore.sortedVehicles.length > 0 && !selectedActionVehicleId.value) {
|
||||
selectedActionVehicleId.value = vehicleStore.sortedVehicles[0].id
|
||||
}
|
||||
}
|
||||
|
||||
function openFuelModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isFuelModalOpen.value = true
|
||||
}
|
||||
|
||||
function openFeeModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isFeeModalOpen.value = true
|
||||
}
|
||||
|
||||
function openComplexModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isComplexModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* F5 Bug fix (RBAC Phase 3): Modal mentés után bezárja a modalt,
|
||||
* és értesíti a szülőt, hogy az új költség megjelent.
|
||||
*/
|
||||
function onModalSaved() {
|
||||
isFuelModalOpen.value = false
|
||||
isFeeModalOpen.value = false
|
||||
isComplexModalOpen.value = false
|
||||
|
||||
// Értesítjük a szülőt, hogy frissítse a VehicleDetailModal költségeit
|
||||
if (selectedActionVehicleId.value) {
|
||||
emit('cost-saved', selectedActionVehicleId.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for vehicles to load, then auto-select
|
||||
watch(() => vehicleStore.vehicles.length, () => {
|
||||
autoSelectVehicle()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
62
frontend/src/components/dashboard/GamificationCard.vue
Normal file
62
frontend/src/components/dashboard/GamificationCard.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="$emit('open-card', 'gamification')"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🏆 {{ t('dashboard.statsAndPoints') }}</span>
|
||||
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
{{ t('dashboard.live') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Score card -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
|
||||
</div>
|
||||
<!-- Achievement badges -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
|
||||
{{ t('dashboard.achievements') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600"
|
||||
>
|
||||
{{ badge.icon }} {{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom CTA button -->
|
||||
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||
{{ t('dashboard.refresh') }} 🏆
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-card': [cardId: string]
|
||||
}>()
|
||||
|
||||
// ── Placeholder badges ──
|
||||
const badges = ref([
|
||||
{ icon: '🌱', label: 'Eco Driver' },
|
||||
{ icon: '📏', label: '10k km Club' },
|
||||
{ icon: '🔧', label: 'Service Pro' },
|
||||
{ icon: '⭐', label: 'Top Rater' },
|
||||
])
|
||||
</script>
|
||||
63
frontend/src/components/dashboard/MyVehiclesCard.vue
Normal file
63
frontend/src/components/dashboard/MyVehiclesCard.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="$emit('open-card', 'vehicles')"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
|
||||
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
|
||||
<div class="flex-1 overflow-y-auto space-y-2">
|
||||
<VehicleCardCompact
|
||||
v-for="vehicle in displayVehicles.slice(0, 3)"
|
||||
:key="vehicle.id"
|
||||
:vehicle="vehicle"
|
||||
@click="$emit('open-card', 'vehicles')"
|
||||
@plate-click="openVehicleDetail(vehicle)"
|
||||
/>
|
||||
<!-- Empty state (only when BOTH real and mock are empty) -->
|
||||
<div
|
||||
v-if="displayVehicles.length === 0"
|
||||
class="flex flex-col items-center justify-center py-6 text-slate-400"
|
||||
>
|
||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import type { VehicleData } from '../../types/vehicle'
|
||||
import VehicleCardCompact from '../vehicle/VehicleCardCompact.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-card': [cardId: string, targetId?: string | null]
|
||||
'open-vehicle-detail': [vehicle: VehicleData]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Display vehicles from the backend API only.
|
||||
* Sorted by is_primary flag (favorite first), then alphabetically.
|
||||
* Shows all real vehicles; no mock/fallback data is injected.
|
||||
*/
|
||||
const displayVehicles = computed(() => {
|
||||
return vehicleStore.sortedVehicles
|
||||
})
|
||||
|
||||
function openVehicleDetail(vehicle: VehicleData) {
|
||||
emit('open-vehicle-detail', vehicle)
|
||||
}
|
||||
</script>
|
||||
60
frontend/src/components/dashboard/ProfileTrustCard.vue
Normal file
60
frontend/src/components/dashboard/ProfileTrustCard.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="$emit('open-card', 'stats')"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🛡️ {{ t('header.profile') }}</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- User info -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
|
||||
</div>
|
||||
<!-- Trust Score -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
|
||||
<span class="text-sm font-bold text-emerald-600">A+</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick stats -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom CTA button -->
|
||||
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||
{{ t('header.profile') }} →
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-card': [cardId: string]
|
||||
}>()
|
||||
</script>
|
||||
68
frontend/src/components/dashboard/ServiceFinderCard.vue
Normal file
68
frontend/src/components/dashboard/ServiceFinderCard.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
|
||||
@click="openExternalServiceFinder"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
|
||||
</div>
|
||||
<!-- Content: 3 simple buttons stacked vertically -->
|
||||
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
|
||||
<button
|
||||
@click.stop="openExternalServiceFinder"
|
||||
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
🔍 {{ t('serviceFinder.plannedMaintenance') }}
|
||||
</button>
|
||||
<button
|
||||
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
|
||||
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
🚨 SOS {{ t('serviceFinder.sosTitle') }}
|
||||
</button>
|
||||
<button
|
||||
@click.stop="isSfQuickAddOpen = true"
|
||||
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
➕ {{ t('provider.quickAddTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Provider Quick Add Modal -->
|
||||
<ProviderQuickAddModal
|
||||
:is-open="isSfQuickAddOpen"
|
||||
@close="isSfQuickAddOpen = false"
|
||||
@saved="onSfQuickAddSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
// ── Service Finder (Card 3) — Launcher ──
|
||||
const isSfQuickAddOpen = ref(false)
|
||||
|
||||
/**
|
||||
* Navigates to the Service Finder page in the same tab.
|
||||
* Used by the card click and the "Tervezett karbantartás" button.
|
||||
*/
|
||||
function openExternalServiceFinder() {
|
||||
router.push('/dashboard/service-finder')
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when ProviderQuickAddModal saves a new provider.
|
||||
*/
|
||||
function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
|
||||
isSfQuickAddOpen.value = false
|
||||
alert(`🎉 ${t('provider.successMessage', { points: 0 })}`)
|
||||
}
|
||||
</script>
|
||||
@@ -190,7 +190,8 @@ async function handleSubmit() {
|
||||
const payload = {
|
||||
asset_id: props.vehicle.id,
|
||||
category_id: fuelCategoryId.value, // FUEL category (resolved by code)
|
||||
amount_net: form.amount,
|
||||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||
amount_gross: form.amount,
|
||||
currency: 'HUF' as const,
|
||||
date: new Date(form.date).toISOString(),
|
||||
mileage_at_cost: form.odometer > 0 ? form.odometer : null,
|
||||
|
||||
@@ -142,12 +142,13 @@ const initials = computed(() => {
|
||||
return '?'
|
||||
})
|
||||
|
||||
// ── Admin check: only admin/superadmin/region_admin/country_admin/moderator ──
|
||||
// ── Admin check: only SUPERADMIN, ADMIN, MODERATOR ──
|
||||
const isAdmin = computed(() => {
|
||||
const role = authStore.user?.role
|
||||
if (!role) return false
|
||||
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
|
||||
return adminRoles.includes(role)
|
||||
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||
return adminRoles.includes(role.toUpperCase())
|
||||
})
|
||||
|
||||
// ── Navigate to Profile ────────────────────────────────────────────
|
||||
|
||||
@@ -75,6 +75,48 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Separator: Subscription Section (P0 Feature) -->
|
||||
<div class="border-t border-white/10 my-2"></div>
|
||||
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.subscriptionTitle') || 'Előfizetés' }}</p>
|
||||
|
||||
<!-- Subscription Tier Dropdown -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.subscriptionTier') || 'Előfizetési csomag' }}</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
v-model="selectedTierId"
|
||||
class="flex-1 px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0]"
|
||||
>
|
||||
<option :value="null" class="bg-[#04151F]">{{ t('company.noSubscription') || 'Nincs csomag' }}</option>
|
||||
<option
|
||||
v-for="tier in availableTiers"
|
||||
:key="tier.id"
|
||||
:value="tier.id"
|
||||
class="bg-[#04151F]"
|
||||
>
|
||||
{{ tier.name }} {{ tier.rules?.allowances?.max_vehicles ? `(${tier.rules.allowances.max_vehicles} jármű)` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="selectedTierId !== org?.subscription_tier_id"
|
||||
@click="assignSubscription"
|
||||
:disabled="isAssigning"
|
||||
class="px-3 py-2 rounded-lg bg-[#00E5A0] hover:bg-[#00E5A0]/80 text-[#04151F] font-semibold text-sm transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isAssigning" class="flex items-center gap-1">
|
||||
<svg class="animate-spin h-4 w-4" 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" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else>{{ t('profile.save') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="org?.subscription_plan" class="text-xs text-white/40 mt-1">
|
||||
{{ t('company.currentPlan') || 'Jelenlegi csomag' }}: {{ org.subscription_plan }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="border-t border-white/10 my-2"></div>
|
||||
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.addressTitle') }}</p>
|
||||
@@ -172,6 +214,11 @@ const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const org = ref<OrganizationItem | null>(null)
|
||||
|
||||
// P0 Feature: Subscription tier assignment
|
||||
const availableTiers = ref<any[]>([])
|
||||
const selectedTierId = ref<number | null>(null)
|
||||
const isAssigning = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
full_name: '',
|
||||
@@ -180,7 +227,9 @@ const form = reactive({
|
||||
address_zip: '',
|
||||
address_city: '',
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
address_hrsz: '',
|
||||
})
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
@@ -193,11 +242,18 @@ onMounted(async () => {
|
||||
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||
if (found) {
|
||||
org.value = found
|
||||
selectedTierId.value = found.subscription_tier_id ?? null
|
||||
// Pre-fill form with existing values
|
||||
form.name = found.name || ''
|
||||
form.full_name = found.full_name || ''
|
||||
form.display_name = found.display_name || ''
|
||||
form.tax_number = found.tax_number || ''
|
||||
form.address_zip = found.address_zip || ''
|
||||
form.address_city = found.address_city || ''
|
||||
form.address_street_name = found.address_street_name || ''
|
||||
form.address_street_type = found.address_street_type || ''
|
||||
form.address_house_number = found.address_house_number || ''
|
||||
form.address_hrsz = found.address_hrsz || ''
|
||||
} else {
|
||||
// Try to fetch fresh data
|
||||
isLoading.value = true
|
||||
@@ -206,10 +262,17 @@ onMounted(async () => {
|
||||
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||
if (refound) {
|
||||
org.value = refound
|
||||
selectedTierId.value = refound.subscription_tier_id ?? null
|
||||
form.name = refound.name || ''
|
||||
form.full_name = refound.full_name || ''
|
||||
form.display_name = refound.display_name || ''
|
||||
form.tax_number = refound.tax_number || ''
|
||||
form.address_zip = refound.address_zip || ''
|
||||
form.address_city = refound.address_city || ''
|
||||
form.address_street_name = refound.address_street_name || ''
|
||||
form.address_street_type = refound.address_street_type || ''
|
||||
form.address_house_number = refound.address_house_number || ''
|
||||
form.address_hrsz = refound.address_hrsz || ''
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load organization:', err)
|
||||
@@ -217,8 +280,44 @@ onMounted(async () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch available subscription tiers
|
||||
try {
|
||||
const res = await api.get('/admin/packages/')
|
||||
availableTiers.value = res.data?.tiers || res.data || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load subscription tiers:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// ── P0 Feature: Assign Subscription Tier ────────────────────────────────────
|
||||
|
||||
async function assignSubscription() {
|
||||
const orgId = Number(route.params.id)
|
||||
if (!orgId || selectedTierId.value === null) return
|
||||
|
||||
isAssigning.value = true
|
||||
try {
|
||||
await api.put(`/organizations/${orgId}/subscription`, {
|
||||
tier_id: selectedTierId.value
|
||||
})
|
||||
|
||||
// Refresh org data in store
|
||||
await authStore.fetchMyOrganizations()
|
||||
const updated = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||
if (updated) {
|
||||
org.value = updated
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
} catch (err: any) {
|
||||
console.error('Failed to assign subscription:', err)
|
||||
alert(err?.response?.data?.detail || 'Failed to assign subscription')
|
||||
} finally {
|
||||
isAssigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save Handler ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
|
||||
@@ -735,8 +735,10 @@
|
||||
Bezárás
|
||||
</button>
|
||||
<!-- Task 1: Szerkesztés gomb csak az Alapadatok fülön látható -->
|
||||
<!-- RBAC Phase 3 (UI Pilot): A gomb csak akkor jelenik meg, ha a user rendelkezik
|
||||
can_approve_expense képességgel az aktív szervezetben -->
|
||||
<button
|
||||
v-if="activeTab === 'basics'"
|
||||
v-if="activeTab === 'basics' && authStore.hasOrgCapability(authStore.user?.active_organization_id ?? 0, 'can_approve_expense')"
|
||||
@click="$emit('edit', vehicle)"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue cursor-pointer"
|
||||
>
|
||||
@@ -769,6 +771,11 @@ const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
vehicle: VehicleData | null
|
||||
/**
|
||||
* F5 Bug fix (RBAC Phase 3): Számláló, ami minden költség mentéskor nő.
|
||||
* A watch figyeli ezt, és újratölti a költségeket, ha a finance tab aktív.
|
||||
*/
|
||||
refreshTrigger?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -850,7 +857,7 @@ interface CostItem {
|
||||
category_name?: string
|
||||
/** Backend returns `category_code` from CostCategory relationship */
|
||||
category_code?: string
|
||||
/** Backend returns `amount_net` (Decimal) — the main monetary value */
|
||||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary monetary value */
|
||||
amount: number
|
||||
/** Backend returns `currency` (e.g. 'HUF') */
|
||||
currency?: string
|
||||
@@ -859,6 +866,7 @@ interface CostItem {
|
||||
/** Backend returns `mileage_at_cost` extracted from data JSONB */
|
||||
mileage_at_cost?: number
|
||||
/** Raw backend fields kept for reference */
|
||||
amount_gross?: number
|
||||
amount_net?: number
|
||||
currency_raw?: string
|
||||
date?: string
|
||||
@@ -870,8 +878,9 @@ const costsLoading = ref(false)
|
||||
|
||||
/**
|
||||
* Map a raw backend AssetCostResponse item to the frontend CostItem shape.
|
||||
* GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary source of truth.
|
||||
* Backend schema (AssetCostResponse) now uses:
|
||||
* amount_net, currency, date, category_name, category_code, description, mileage_at_cost
|
||||
* amount_gross, amount_net, currency, date, category_name, category_code, description, mileage_at_cost
|
||||
*/
|
||||
function mapBackendCost(raw: any): CostItem {
|
||||
return {
|
||||
@@ -880,11 +889,12 @@ function mapBackendCost(raw: any): CostItem {
|
||||
category: raw.category_name || raw.category_code || raw.category || '',
|
||||
category_name: raw.category_name,
|
||||
category_code: raw.category_code,
|
||||
amount: Number(raw.amount_net ?? raw.amount ?? 0),
|
||||
amount: Number(raw.amount_gross ?? raw.amount_net ?? raw.amount ?? 0),
|
||||
currency: raw.currency || 'HUF',
|
||||
description: raw.description || raw.data?.description || '',
|
||||
mileage_at_cost: raw.mileage_at_cost ?? raw.data?.mileage_at_cost,
|
||||
// Keep raw fields for reference
|
||||
amount_gross: raw.amount_gross,
|
||||
amount_net: raw.amount_net,
|
||||
currency_raw: raw.currency,
|
||||
date: raw.date,
|
||||
@@ -928,6 +938,23 @@ watch(() => props.vehicle, (newVehicle) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* F5 Bug fix (RBAC Phase 3): Re-fetch costs when refreshTrigger changes.
|
||||
* A DashboardView növeli ezt a számlálót, amikor a CostsActionsCard 'cost-saved' eventet emitál.
|
||||
*/
|
||||
watch(() => props.refreshTrigger, (newVal, oldVal) => {
|
||||
if (newVal !== undefined && newVal !== oldVal && activeTab.value === 'finance' && props.vehicle?.id) {
|
||||
costsLoading.value = true
|
||||
api.get(`/assets/${props.vehicle.id}/costs`)
|
||||
.then(res => {
|
||||
const rawData = (res.data as any[]) || []
|
||||
vehicleCosts.value = rawData.map(mapBackendCost)
|
||||
})
|
||||
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
|
||||
.finally(() => { costsLoading.value = false })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Format date helper ──
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '—'
|
||||
|
||||
@@ -132,8 +132,10 @@ router.beforeEach(async (to, _from, next) => {
|
||||
// If the route requires admin privileges, check the user's role.
|
||||
if (to.meta.requiresAdmin) {
|
||||
const role = authStore.user?.role
|
||||
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
|
||||
if (!role || !adminRoles.includes(role)) {
|
||||
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||
// Must use toUpperCase() to ensure case-insensitive matching
|
||||
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||
if (!role || !adminRoles.includes(role.toUpperCase())) {
|
||||
// Non-admin user trying to access admin area → redirect to dashboard
|
||||
return next('/dashboard')
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ export interface UserProfile {
|
||||
person_id: number | null
|
||||
// Nested person data with address
|
||||
person: PersonData | null
|
||||
// RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból)
|
||||
system_capabilities?: Record<string, boolean>
|
||||
// RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
|
||||
org_capabilities?: Record<string, Record<string, boolean>>
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -87,6 +91,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return !!user.value?.person_id
|
||||
})
|
||||
const userRole = computed(() => user.value?.role ?? 'guest')
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e admin jogosultsággal.
|
||||
* A SUPERADMIN és ADMIN (és egyéb adminisztratív) szerepköröket ismeri fel.
|
||||
*/
|
||||
const isAdmin = computed(() => {
|
||||
const role = user.value?.role
|
||||
if (!role) return false
|
||||
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||
// Must use toUpperCase() to ensure case-insensitive matching
|
||||
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||
return adminRoles.includes(role.toUpperCase())
|
||||
})
|
||||
const userName = computed(() => {
|
||||
if (!user.value) return ''
|
||||
const { first_name, last_name } = user.value
|
||||
@@ -95,6 +112,41 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
})
|
||||
const userId = computed(() => user.value?.id ?? null)
|
||||
|
||||
// ── RBAC Phase 3: Capability Helpers ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott rendszerszintű képességgel.
|
||||
* A képességek a backend /auth/me vagy /users/me végpontról érkeznek
|
||||
* a SYSTEM_CAPABILITIES_MATRIX alapján.
|
||||
* @param capability - A képesség neve (pl. 'can_manage_users')
|
||||
* @returns true, ha a képesség elérhető a user szerepköréhez
|
||||
*/
|
||||
function hasSystemCapability(capability: string): boolean {
|
||||
return user.value?.system_capabilities?.[capability] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott szervezeti képességgel.
|
||||
* A szervezeti képességek az OrgRole.permissions JSONB mezőből származnak.
|
||||
* @param orgId - A szervezet azonosítója (string vagy number)
|
||||
* @param capability - A képesség neve (pl. 'can_approve_expense')
|
||||
* @returns true, ha a képesség elérhető a user számára az adott szervezetben
|
||||
*/
|
||||
function hasOrgCapability(orgId: string | number, capability: string): boolean {
|
||||
const orgCaps = user.value?.org_capabilities?.[String(orgId)]
|
||||
if (!orgCaps) return false
|
||||
return orgCaps[capability] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Visszaadja az összes szervezeti képességet egy adott szervezethez.
|
||||
* @param orgId - A szervezet azonosítója
|
||||
* @returns A képességek objektuma, vagy üres objektum, ha nincs
|
||||
*/
|
||||
function getOrgCapabilities(orgId: string | number): Record<string, boolean> {
|
||||
return user.value?.org_capabilities?.[String(orgId)] || {}
|
||||
}
|
||||
|
||||
// ────────────────────────────── Helpers ────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -671,6 +723,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isKycComplete,
|
||||
userRole,
|
||||
userName,
|
||||
@@ -679,6 +732,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
hasBusinessOrganization,
|
||||
isCorporateMode,
|
||||
|
||||
// RBAC Phase 3: Capability Helpers
|
||||
hasSystemCapability,
|
||||
hasOrgCapability,
|
||||
getOrgCapabilities,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
fetchUser,
|
||||
|
||||
@@ -4,22 +4,71 @@ import { ref, computed } from 'vue'
|
||||
export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
const isAuthenticated = ref(false)
|
||||
const userRole = ref<UserRole>('guest')
|
||||
const userName = ref('')
|
||||
const userId = ref<number | null>(null)
|
||||
|
||||
// Getters
|
||||
/**
|
||||
* RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból).
|
||||
* Pl. { can_manage_users: false, can_manage_own_vehicles: true, ... }
|
||||
*/
|
||||
const systemCapabilities = ref<Record<string, boolean>>({})
|
||||
|
||||
/**
|
||||
* RBAC Phase 3: Szervezeti szintű képességek.
|
||||
* Kulcs: organization_id (string), Érték: { capability_name: boolean }
|
||||
* Pl. { "5": { can_approve_expense: true, can_add_expense: true } }
|
||||
*/
|
||||
const orgCapabilities = ref<Record<string, Record<string, boolean>>>({})
|
||||
|
||||
// ── Getters ────────────────────────────────────────────────────────
|
||||
const isAdmin = computed(() => {
|
||||
return userRole.value === 'admin' || userRole.value === 'superadmin'
|
||||
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||
// Must use toUpperCase() to ensure case-insensitive matching
|
||||
const role = userRole.value.toUpperCase()
|
||||
return role === 'ADMIN' || role === 'SUPERADMIN'
|
||||
})
|
||||
|
||||
const isSuperAdmin = computed(() => userRole.value === 'superadmin')
|
||||
const isManager = computed(() => userRole.value === 'manager')
|
||||
const isRegularUser = computed(() => userRole.value === 'user')
|
||||
const isSuperAdmin = computed(() => userRole.value.toUpperCase() === 'SUPERADMIN')
|
||||
const isManager = computed(() => userRole.value.toUpperCase() === 'MANAGER')
|
||||
const isRegularUser = computed(() => userRole.value.toUpperCase() === 'USER')
|
||||
|
||||
// Actions
|
||||
// ── RBAC Phase 3: Capability Helpers ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott rendszerszintű képességgel.
|
||||
* @param capability - A képesség neve (pl. 'can_manage_users')
|
||||
* @returns true, ha a képesség elérhető a user szerepköréhez
|
||||
*/
|
||||
function hasSystemCapability(capability: string): boolean {
|
||||
return systemCapabilities.value[capability] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott szervezeti képességgel.
|
||||
* @param orgId - A szervezet azonosítója (string vagy number)
|
||||
* @param capability - A képesség neve (pl. 'can_approve_expense')
|
||||
* @returns true, ha a képesség elérhető a user számára az adott szervezetben
|
||||
*/
|
||||
function hasOrgCapability(orgId: string | number, capability: string): boolean {
|
||||
const orgIdStr = String(orgId)
|
||||
const orgCaps = orgCapabilities.value[orgIdStr]
|
||||
if (!orgCaps) return false
|
||||
return orgCaps[capability] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Visszaadja az összes szervezeti képességet egy adott szervezethez.
|
||||
* @param orgId - A szervezet azonosítója
|
||||
* @returns A képességek objektuma, vagy üres objektum, ha nincs
|
||||
*/
|
||||
function getOrgCapabilities(orgId: string | number): Record<string, boolean> {
|
||||
return orgCapabilities.value[String(orgId)] || {}
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
function login(role: UserRole, name: string, id: number) {
|
||||
isAuthenticated.value = true
|
||||
userRole.value = role
|
||||
@@ -33,6 +82,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
userRole.value = 'guest'
|
||||
userName.value = ''
|
||||
userId.value = null
|
||||
systemCapabilities.value = {}
|
||||
orgCapabilities.value = {}
|
||||
localStorage.removeItem('auth')
|
||||
}
|
||||
|
||||
@@ -51,6 +102,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RBAC Phase 3: Frissíti a képességeket a backend /users/me válaszából.
|
||||
* @param systemCaps - Rendszerszintű képességek objektuma
|
||||
* @param orgCaps - Szervezeti képességek objektuma (org_id -> { capability: bool })
|
||||
*/
|
||||
function setCapabilities(
|
||||
systemCaps: Record<string, boolean>,
|
||||
orgCaps: Record<string, Record<string, boolean>>
|
||||
) {
|
||||
systemCapabilities.value = systemCaps
|
||||
orgCapabilities.value = orgCaps
|
||||
}
|
||||
|
||||
// Simulate fetching user role from backend
|
||||
async function fetchUserRole() {
|
||||
// In a real app, this would be an API call
|
||||
@@ -69,17 +133,25 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
userRole,
|
||||
userName,
|
||||
userId,
|
||||
|
||||
systemCapabilities,
|
||||
orgCapabilities,
|
||||
|
||||
// Getters
|
||||
isAdmin,
|
||||
isSuperAdmin,
|
||||
isManager,
|
||||
isRegularUser,
|
||||
|
||||
|
||||
// RBAC Phase 3: Capability Helpers
|
||||
hasSystemCapability,
|
||||
hasOrgCapability,
|
||||
getOrgCapabilities,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
init,
|
||||
fetchUserRole
|
||||
setCapabilities,
|
||||
fetchUserRole,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,8 @@ export interface AddExpensePayload {
|
||||
asset_id: string
|
||||
organization_id?: number | null // Auto-resolved by backend from asset if omitted
|
||||
category_id: number
|
||||
amount_net: number
|
||||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary monetary value */
|
||||
amount_gross: number
|
||||
currency?: string
|
||||
date: string
|
||||
mileage_at_cost?: number | null
|
||||
@@ -25,7 +26,7 @@ export interface ExpenseResponse {
|
||||
id: string
|
||||
asset_id: string
|
||||
cost_category: string
|
||||
amount_net: number
|
||||
amount_gross: number
|
||||
date: string
|
||||
}
|
||||
|
||||
@@ -51,7 +52,8 @@ export const useCostStore = defineStore('cost', () => {
|
||||
const body: Record<string, any> = {
|
||||
asset_id: payload.asset_id,
|
||||
category_id: payload.category_id,
|
||||
amount_net: payload.amount_net,
|
||||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||
amount_gross: payload.amount_gross,
|
||||
currency: payload.currency || 'HUF',
|
||||
date: payload.date,
|
||||
mileage_at_cost: payload.mileage_at_cost ?? null,
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface OrganizationItem {
|
||||
is_active: boolean
|
||||
is_deleted: boolean
|
||||
subscription_plan: string
|
||||
/** P0 Feature: FK to system.subscription_tiers — the assigned subscription package */
|
||||
subscription_tier_id?: number | null
|
||||
/** org_type is not in the current /my response but we keep it for future use */
|
||||
org_type?: string
|
||||
/**
|
||||
@@ -27,4 +29,11 @@ export interface OrganizationItem {
|
||||
* Used by the frontend to decide whether to show service_provider orgs in the garage switcher.
|
||||
*/
|
||||
user_role?: string
|
||||
// ── Cím adatok (Address fields) ──
|
||||
address_zip?: string | null
|
||||
address_city?: string | null
|
||||
address_street_name?: string | null
|
||||
address_street_type?: string | null
|
||||
address_house_number?: string | null
|
||||
address_hrsz?: string | null
|
||||
}
|
||||
|
||||
@@ -52,243 +52,27 @@
|
||||
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)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="openCard('vehicles')"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
|
||||
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
|
||||
<div class="flex-1 overflow-y-auto space-y-2">
|
||||
<VehicleCardCompact
|
||||
v-for="vehicle in displayVehicles.slice(0, 3)"
|
||||
:key="vehicle.id"
|
||||
:vehicle="vehicle"
|
||||
@click="openCard('vehicles')"
|
||||
@plate-click="openVehicleDetail(vehicle)"
|
||||
/>
|
||||
<!-- Empty state (only when BOTH real and mock are empty) -->
|
||||
<div
|
||||
v-if="displayVehicles.length === 0"
|
||||
class="flex flex-col items-center justify-center py-6 text-slate-400"
|
||||
>
|
||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 1: 🚗 My Garage (Járműveim) -->
|
||||
<MyVehiclesCard
|
||||
@open-card="openCard"
|
||||
@open-vehicle-detail="openVehicleDetail"
|
||||
/>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 2: 💰 Costs & Quick Actions (Action Center)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2">
|
||||
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<!-- Vehicle Selector Dropdown -->
|
||||
<div class="mb-3">
|
||||
<label class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1 block">{{ t('dashboard.vehicle') }}</label>
|
||||
<select
|
||||
v-model="selectedActionVehicleId"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
>
|
||||
<option
|
||||
v-for="v in vehicleStore.sortedVehicles"
|
||||
:key="v.id"
|
||||
:value="v.id"
|
||||
>
|
||||
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Card 2: 💰 Costs & Quick Actions (Action Center) -->
|
||||
<CostsActionsCard @cost-saved="onCostSaved" />
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex-1 flex flex-col gap-2.5 justify-center">
|
||||
<!-- No vehicle warning -->
|
||||
<div
|
||||
v-if="!selectedActionVehicle"
|
||||
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
<span>⚠️ {{ t('dashboard.noVehicleSelected') }}</span>
|
||||
</div>
|
||||
<!-- Card 3: 🔧 Service Finder (3-Way Indítópult) -->
|
||||
<ServiceFinderCard />
|
||||
|
||||
<!-- ⛽ Record Fueling (Big, prominent) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openFuelModal"
|
||||
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg">⛽</span>
|
||||
<span>{{ t('dashboard.recordFueling') }}</span>
|
||||
<span class="ml-auto text-white/60 text-xs">→</span>
|
||||
</button>
|
||||
<!-- Card 4: 🏆 Gamification (Játékosítás) -->
|
||||
<GamificationCard
|
||||
@open-card="openCard"
|
||||
/>
|
||||
|
||||
<!-- 🅿️ Parking / Toll (Medium) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openFeeModal"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🅿️</span>
|
||||
<span>{{ t('dashboard.parkingToll') }}</span>
|
||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<!-- ➕ Additional Costs (Medium) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openComplexModal"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">➕</span>
|
||||
<span>{{ t('dashboard.additionalCosts') }}</span>
|
||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 3: 🔧 Service Finder (3-Way Indítópult)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
|
||||
@click="openExternalServiceFinder"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
|
||||
</div>
|
||||
<!-- Content: 3 simple buttons stacked vertically -->
|
||||
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
|
||||
<button
|
||||
@click.stop="openExternalServiceFinder"
|
||||
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
🔍 {{ t('serviceFinder.plannedMaintenance') }}
|
||||
</button>
|
||||
<button
|
||||
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
|
||||
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
🚨 SOS {{ t('serviceFinder.sosTitle') }}
|
||||
</button>
|
||||
<button
|
||||
@click.stop="isSfQuickAddOpen = true"
|
||||
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
➕ {{ t('provider.quickAddTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 4: 🏆 Gamification (Játékosítás)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="openCard('gamification')"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🏆 {{ t('dashboard.statsAndPoints') }}</span>
|
||||
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
{{ t('dashboard.live') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Score card -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
|
||||
</div>
|
||||
<!-- Achievement badges -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
|
||||
{{ t('dashboard.achievements') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600"
|
||||
>
|
||||
{{ badge.icon }} {{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom CTA button -->
|
||||
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||
{{ t('dashboard.refresh') }} 🏆
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 5: 🛡️ My Profile & Trust (Profil & Trust Score)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="openCard('stats')"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🛡️ {{ t('header.profile') }}</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- User info -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
|
||||
</div>
|
||||
<!-- Trust Score -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
|
||||
<span class="text-sm font-bold text-emerald-600">A+</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick stats -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom CTA button -->
|
||||
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||
{{ t('header.profile') }} →
|
||||
</button>
|
||||
</div>
|
||||
<!-- Card 5: 🛡️ My Profile & Trust (Profil & Trust Score) -->
|
||||
<ProfileTrustCard
|
||||
@open-card="openCard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,36 +121,13 @@
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Cost Action Modals (Teleported to body)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<SimpleFuelModal
|
||||
:is-open="isFuelModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
@close="isFuelModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
<ComplexExpenseModal
|
||||
:is-open="isFeeModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
:is-fee-mode="true"
|
||||
@close="isFeeModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
<ComplexExpenseModal
|
||||
:is-open="isComplexModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
:is-fee-mode="false"
|
||||
@close="isComplexModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
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"
|
||||
@@ -391,32 +152,29 @@
|
||||
@saved="onEditVehicleSaved"
|
||||
@deleted="onVehicleDeleted"
|
||||
/>
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Provider Quick Add Modal (Service Finder Card 3)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<ProviderQuickAddModal
|
||||
:is-open="isSfQuickAddOpen"
|
||||
@close="isSfQuickAddOpen = false"
|
||||
@saved="onSfQuickAddSaved"
|
||||
/>
|
||||
</div><!-- end min-h-screen -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
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 VehicleCardCompact from '../components/vehicle/VehicleCardCompact.vue'
|
||||
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
|
||||
import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue'
|
||||
import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue'
|
||||
import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue'
|
||||
import ProviderQuickAddModal from '../components/provider/ProviderQuickAddModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -437,30 +195,16 @@ const openCard = (cardId: string, targetId: string | null = null) => {
|
||||
selectedTargetId.value = targetId
|
||||
}
|
||||
|
||||
// ── Service Finder (Card 3) — Launcher ──
|
||||
const isSearchModalOpen = ref(false)
|
||||
const isSfQuickAddOpen = ref(false)
|
||||
|
||||
/**
|
||||
* Navigates to the Service Finder page in the same tab.
|
||||
* Used by the card click and the "Tervezett karbantartás" button.
|
||||
*/
|
||||
function openExternalServiceFinder() {
|
||||
router.push('/dashboard/service-finder')
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when ProviderQuickAddModal saves a new provider.
|
||||
*/
|
||||
function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
|
||||
isSfQuickAddOpen.value = false
|
||||
alert(`🎉 ${t('provider.successMessage', { points: 0 })}`)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
@@ -471,6 +215,15 @@ function closeVehicleDetail() {
|
||||
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)
|
||||
@@ -586,71 +339,6 @@ function handleSetPrimaryFromDetail(vehicle: VehicleData) {
|
||||
vehicleStore.setPrimaryVehicle(vehicle.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display vehicles from the backend API only.
|
||||
* Sorted by is_primary flag (favorite first), then alphabetically.
|
||||
* Shows all real vehicles; no mock/fallback data is injected.
|
||||
*/
|
||||
const displayVehicles = computed(() => {
|
||||
return vehicleStore.sortedVehicles
|
||||
})
|
||||
|
||||
// ── Cost Card Action Center State ──
|
||||
const selectedActionVehicleId = ref<string>('')
|
||||
const isFuelModalOpen = ref(false)
|
||||
const isFeeModalOpen = ref(false)
|
||||
const isComplexModalOpen = ref(false)
|
||||
|
||||
/** The currently selected vehicle object for the action modals */
|
||||
const selectedActionVehicle = computed(() => {
|
||||
if (!selectedActionVehicleId.value) return null
|
||||
return vehicleStore.vehicles.find(v => v.id === selectedActionVehicleId.value) || null
|
||||
})
|
||||
|
||||
/** Auto-select the first (primary/sorted) vehicle on load */
|
||||
function autoSelectVehicle() {
|
||||
if (vehicleStore.sortedVehicles.length > 0 && !selectedActionVehicleId.value) {
|
||||
selectedActionVehicleId.value = vehicleStore.sortedVehicles[0].id
|
||||
}
|
||||
}
|
||||
|
||||
function openFuelModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isFuelModalOpen.value = true
|
||||
}
|
||||
|
||||
function openFeeModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isFeeModalOpen.value = true
|
||||
}
|
||||
|
||||
function openComplexModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isComplexModalOpen.value = true
|
||||
}
|
||||
|
||||
function onModalSaved() {
|
||||
isFuelModalOpen.value = false
|
||||
isFeeModalOpen.value = false
|
||||
isComplexModalOpen.value = false
|
||||
}
|
||||
|
||||
// ── Placeholder notifications ──
|
||||
const notifications = ref([
|
||||
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
|
||||
{ icon: '⛽', title: 'Fuel cost updated — +₿ 0.08', time: '5 hours ago' },
|
||||
{ icon: '🏆', title: 'You earned "Eco Driver" badge!', time: '1 day ago' },
|
||||
{ icon: '📊', title: 'Monthly report is ready', time: '2 days ago' },
|
||||
])
|
||||
|
||||
// ── Placeholder badges ──
|
||||
const badges = ref([
|
||||
{ icon: '🌱', label: 'Eco Driver' },
|
||||
{ icon: '📏', label: '10k km Club' },
|
||||
{ icon: '🔧', label: 'Service Pro' },
|
||||
{ icon: '⭐', label: 'Top Rater' },
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
vehicleStore.fetchVehicles()
|
||||
authStore.fetchMyOrganizations()
|
||||
@@ -664,11 +352,6 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for vehicles to load, then auto-select
|
||||
watch(() => vehicleStore.vehicles.length, () => {
|
||||
autoSelectVehicle()
|
||||
}, { immediate: true })
|
||||
|
||||
// ── Watch for query param changes (hamburger menu navigation) ────
|
||||
watch(() => route.query.card, (newCard) => {
|
||||
if (newCard && typeof newCard === 'string') {
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
<!-- Hard Delete: Only visible for Superadmin -->
|
||||
<button
|
||||
v-if="authStore.user?.role === 'superadmin'"
|
||||
v-if="authStore.user?.role?.toUpperCase() === 'SUPERADMIN'"
|
||||
class="px-3 py-1.5 bg-red-800 hover:bg-red-900 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="bulkLoading"
|
||||
@click="executeBulkAction('hard_delete')"
|
||||
@@ -296,7 +296,7 @@
|
||||
</button>
|
||||
<div class="border-t border-gray-600 my-1"></div>
|
||||
<button
|
||||
v-if="authStore.user?.role === 'superadmin'"
|
||||
v-if="authStore.user?.role?.toUpperCase() === 'SUPERADMIN'"
|
||||
class="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
@click="individualAction(user.id, 'hard_delete')"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user