admin frontend elkezdése, járművek tisztázása, frontend fejleszts

This commit is contained in:
Roo
2026-06-15 18:52:38 +00:00
parent ef8df9608c
commit 213ba3b0f1
80 changed files with 11615 additions and 2304 deletions

View File

@@ -141,8 +141,8 @@
</label>
</div>
<!-- Symmetric Link Row: Resend (left) + Forgot Password (right) -->
<div class="flex justify-between items-center w-full mt-2 mb-6 text-sm">
<!-- Link Row: Resend (left) + Forgot Password (right) -->
<div class="flex justify-between items-center w-full mt-2 mb-2 text-sm">
<a
href="#"
@click.prevent="switchMode('resend')"
@@ -159,6 +159,17 @@
</a>
</div>
<!-- Account Restore Link -->
<div class="flex justify-center mb-6">
<a
href="#"
@click.prevent="switchMode('restore')"
class="text-xs text-[#D4AF37]/60 hover:text-[#D4AF37] transition-colors"
>
{{ t('auth.restoreLink') }}
</a>
</div>
<!-- Error Message (translated via i18n) -->
<div
v-if="authStore.error && authMode === 'login'"
@@ -613,6 +624,209 @@
</a>
</p>
</form>
<!-- ==================== FIFTH FACE: ACCOUNT RESTORE ==================== -->
<form
v-if="activeBackFace === 'restore'"
@submit.prevent="handleRestoreStep"
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
>
<!-- Close Button (X) -->
<button
type="button"
@click="$emit('close')"
class="absolute right-4 top-4 text-white/60 transition-colors hover:text-white"
>
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<!-- Title -->
<h2 class="mb-6 text-center text-2xl font-bold text-white">
{{ t('auth.restoreTitle') }}
</h2>
<!-- Step 1: Email + Send Code -->
<template v-if="restoreStep === 1">
<p class="mb-6 rounded-xl bg-[#1A6B5A]/20 px-4 py-3 text-center text-sm text-[#5EC4B0]">
{{ t('auth.restoreStep1') }}
</p>
<!-- Email Input -->
<div class="mb-6">
<label for="restore-email" class="mb-2 block text-sm font-medium text-white/80">
{{ t('auth.restoreEmailLabel') }}
</label>
<input
id="restore-email"
v-model="restoreEmail"
type="email"
placeholder="pelda@email.com"
autocomplete="email"
required
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- Error Message -->
<div
v-if="restoreError"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
>
{{ translateError(restoreError) }}
</div>
<!-- Send Code Button -->
<button
type="submit"
:disabled="authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.restoreSendCode') }}
</button>
</template>
<!-- Step 2: OTP + New Password -->
<template v-else-if="restoreStep === 2">
<p class="mb-6 rounded-xl bg-[#1A6B5A]/20 px-4 py-3 text-center text-sm text-[#5EC4B0]">
{{ t('auth.restoreStep2') }}
</p>
<!-- Code Sent Info -->
<div class="mb-4 rounded-xl bg-[#1A6B5A]/10 px-4 py-2 text-center text-xs text-[#5EC4B0]">
{{ t('auth.restoreCodeSent') }}
</div>
<!-- OTP Input -->
<div class="mb-5">
<label for="restore-otp" class="mb-2 block text-sm font-medium text-white/80">
{{ t('auth.restoreOtpLabel') }}
</label>
<input
id="restore-otp"
v-model="restoreOtp"
type="text"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
placeholder="123456"
autocomplete="one-time-code"
required
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- New Password Input -->
<div class="mb-6 relative">
<label for="restore-password" class="mb-2 block text-sm font-medium text-white/80">
{{ t('auth.restoreNewPassword') }}
</label>
<input
id="restore-password"
v-model="restoreNewPassword"
:type="showRestorePassword ? 'text' : 'password'"
placeholder="••••••••"
autocomplete="new-password"
required
minlength="6"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 pr-12 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
<button
type="button"
@mousedown="showRestorePassword = true"
@mouseup="showRestorePassword = false"
@mouseleave="showRestorePassword = false"
@touchstart.prevent="showRestorePassword = true"
@touchend="showRestorePassword = false"
class="absolute right-3 top-[42px] text-white/40 hover:text-white/70 transition-colors cursor-pointer"
tabindex="-1"
aria-label="Jelszó megtekintése"
>
<svg
v-if="showRestorePassword"
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<path d="M1 1l22 22" />
</svg>
<svg
v-else
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
<!-- Error Message -->
<div
v-if="restoreError"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
>
{{ translateError(restoreError) }}
</div>
<!-- Verify Button -->
<button
type="submit"
:disabled="authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.restoreVerifyButton') }}
</button>
</template>
<!-- Success Message -->
<template v-else-if="restoreStep === 3">
<div class="mb-6 rounded-xl bg-[#70BC84]/20 px-4 py-6 text-center">
<div class="mb-3 flex justify-center">
<svg class="h-12 w-12 text-[#70BC84]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-sm font-medium text-[#70BC84]">
{{ t('auth.restoreSuccess') }}
</p>
</div>
</template>
<!-- Back to Login -->
<p class="mt-6 text-center text-sm text-white/50">
<a
href="#"
@click.prevent="switchMode('login')"
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
>
{{ t('auth.backToLogin') }}
</a>
</p>
</form>
</div>
</div>
</div>
@@ -710,8 +924,16 @@ const showRegPassword = ref(false)
const rememberMe = ref(localStorage.getItem('remember-me-state') === 'true')
// ── 3D Flip state machine ──
const authMode = ref<'login' | 'register' | 'forgot' | 'resend'>('login')
const activeBackFace = ref<'register' | 'forgot' | 'resend'>('register')
const authMode = ref<'login' | 'register' | 'forgot' | 'resend' | 'restore'>('login')
const activeBackFace = ref<'register' | 'forgot' | 'resend' | 'restore'>('register')
// ── Account Restore form fields ──
const restoreEmail = ref('')
const restoreOtp = ref('')
const restoreNewPassword = ref('')
const restoreError = ref('')
const restoreStep = ref(1) // 1 = email, 2 = otp+password, 3 = success
const showRestorePassword = ref(false)
// ── Restore saved email, remember-me state, and resend cooldown on mount ──
onMounted(() => {
@@ -763,6 +985,12 @@ watch(
// Reset view to login
authMode.value = 'login'
activeBackFace.value = 'register'
// Reset restore state
restoreStep.value = 1
restoreEmail.value = ''
restoreOtp.value = ''
restoreNewPassword.value = ''
restoreError.value = ''
forgotError.value = ''
forgotSuccess.value = false
isUserInactive.value = false
@@ -779,11 +1007,19 @@ watch(authMode, () => {
authStore.clearError()
})
function switchMode(mode: 'login' | 'register' | 'forgot' | 'resend') {
function switchMode(mode: 'login' | 'register' | 'forgot' | 'resend' | 'restore') {
if (mode !== 'login') {
activeBackFace.value = mode
}
authMode.value = mode
// Reset restore state when switching away from restore
if (mode !== 'restore') {
restoreStep.value = 1
restoreEmail.value = ''
restoreOtp.value = ''
restoreNewPassword.value = ''
restoreError.value = ''
}
}
/**
@@ -903,6 +1139,52 @@ async function handleResend() {
resendError.value = errMsg || 'auth.resendFailed'
}
}
// ── Account Restore Logic ──
async function handleRestoreStep() {
restoreError.value = ''
if (restoreStep.value === 1) {
// Step 1: Send restoration code to email
try {
await authStore.requestRestore(restoreEmail.value)
// Move to step 2
restoreStep.value = 2
} catch (err: any) {
// Capture error from store — display user-friendly message
restoreError.value = authStore.error || err?.response?.data?.detail || 'auth.restoreError'
// Log the actual error for debugging
console.warn('Account restore step 1 failed:', err)
} finally {
// GUARANTEE: loading state is always reset, even if catch throws
authStore.isLoading = false
}
} else if (restoreStep.value === 2) {
// Step 2: Verify OTP + set new password
try {
await authStore.verifyRestore(restoreEmail.value, restoreOtp.value, restoreNewPassword.value)
// Success — show success message
restoreStep.value = 3
// Auto-close modal and redirect after short delay
setTimeout(() => {
emit('close')
if (authStore.isKycComplete) {
router.push('/dashboard')
} else {
router.push('/complete-kyc')
}
}, 2000)
} catch (err: any) {
// Capture error from store — display user-friendly message
restoreError.value = authStore.error || err?.response?.data?.detail || 'auth.restoreError'
// Log the actual error for debugging
console.warn('Account restore step 2 failed:', err)
} finally {
// GUARANTEE: loading state is always reset, even if catch throws
authStore.isLoading = false
}
}
}
</script>
<style scoped>

View File

@@ -0,0 +1,81 @@
<template>
<div class="mode-switcher">
<div class="flex items-center space-x-4">
<!-- B2B/B2C Toggle -->
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('switchMode') }}</span>
<button
@click="toggleAppMode"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="isCorporate ? 'bg-corporate-blue' : 'bg-consumer-orange'"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="isCorporate ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
<span class="text-sm font-medium min-w-[60px] dark:text-gray-300">
{{ isCorporate ? $t('corporate') : $t('consumer') }}
</span>
</div>
<!-- Admin Switch (only for admins) -->
<div v-if="showAdminSwitch" class="flex items-center space-x-2">
<div class="h-6 w-px bg-gray-300 dark:bg-gray-600"></div>
<button
@click="goToAdmin"
class="flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-gradient-to-r from-gray-800 to-gray-900 dark:from-gray-700 dark:to-gray-800 text-white hover:opacity-90 transition-opacity"
>
<span class="text-sm"></span>
<span class="text-sm font-medium">Admin</span>
</button>
<button
v-if="isInAdminMode"
@click="exitAdmin"
class="px-3 py-1.5 rounded-lg bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm font-medium transition-colors"
>
Exit Admin
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppModeStore } from '../stores/appModeStore'
import { useAuthStore } from '../stores/auth'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const appModeStore = useAppModeStore()
const authStore = useAuthStore()
const isCorporate = computed(() => appModeStore.isCorporate)
const isInAdminMode = computed(() => route.path.startsWith('/admin'))
const showAdminSwitch = computed(() => {
// Check if user has admin privileges
return authStore.isAdmin
})
function toggleAppMode() {
appModeStore.toggleMode()
}
function goToAdmin() {
router.push('/admin')
}
function exitAdmin() {
router.push('/')
}
</script>
<style scoped>
.mode-switcher {
font-family: 'Inter', sans-serif;
}
</style>

View File

@@ -0,0 +1,482 @@
<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-gray-800 border border-gray-700 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-5 border-b border-gray-700">
<h2 class="text-xl font-bold text-white">
{{ isEditing ? $t('admin.packages.modal_edit_title') : $t('admin.packages.modal_create_title') }}
</h2>
<button
class="p-2 text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded-lg transition-colors"
@click="$emit('close')"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-5 space-y-6">
<!-- Display Name (human-readable) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_display_name') || 'Megjelenített név' }}
</label>
<input
v-model="form.display_name"
type="text"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Pl. Privát Ingyenes"
/>
<p class="text-xs text-gray-500 mt-1">Emberi olvasásra szánt megjelenítési név</p>
</div>
<!-- Name (technical) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_name') }}
</label>
<input
v-model="form.name"
type="text"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
:placeholder="$t('admin.packages.field_name_placeholder')"
/>
<p class="text-xs text-gray-500 mt-1">Technikai azonosító (pl. private_free_v1)</p>
</div>
<!-- Type -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_type') }}
</label>
<select
v-model="form.type"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="private">{{ $t('admin.packages.type_private') }}</option>
<option value="corporate">{{ $t('admin.packages.type_corporate') }}</option>
</select>
</div>
<!-- Pricing Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_monthly_price') }}
</label>
<input
v-model.number="form.monthly_price"
type="number"
step="0.01"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_yearly_price') }}
</label>
<input
v-model.number="form.yearly_price"
type="number"
step="0.01"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_currency') }}
</label>
<select
v-model="form.currency"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="EUR">EUR</option>
<option value="HUF">HUF</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
<!-- Allowances Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_max_vehicles') }}
</label>
<input
v-model.number="form.max_vehicles"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_max_garages') }}
</label>
<input
v-model.number="form.max_garages"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_free_credits') }}
</label>
<input
v-model.number="form.monthly_free_credits"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Credit Price (Kredit Ár) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_credit_price') }}
</label>
<input
v-model.number="form.credit_price"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<p class="text-xs text-gray-500 mt-1">Havi kredit költség a csomaghoz</p>
</div>
<!-- Lifecycle Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_is_public') }}
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="form.is_public"
type="checkbox"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm text-gray-300">{{ form.is_public ? $t('admin.packages.active') : $t('admin.packages.inactive') }}</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_available_until') }}
</label>
<input
v-model="form.available_until"
type="date"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p class="text-xs text-gray-500 mt-1">Ha üres, nincs lejárat</p>
</div>
</div>
<!-- Affiliate Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_commission_rate') }}
</label>
<input
v-model.number="form.commission_rate_percent"
type="number"
min="0"
max="100"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_referral_bonus') }}
</label>
<input
v-model.number="form.referral_bonus_credits"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Entitlements (Chip Selector) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
{{ $t('admin.packages.field_entitlements') }}
</label>
<div class="flex flex-wrap gap-2 mb-3">
<span
v-for="ent in selectedEntitlements"
:key="ent"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-900/50 text-blue-300 border border-blue-700 rounded-full text-sm"
>
{{ ent }}
<button
class="p-0.5 hover:bg-blue-800 rounded-full transition-colors"
@click="removeEntitlement(ent)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" 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>
</span>
</div>
<div class="relative">
<select
v-model="entitlementToAdd"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="addEntitlement"
>
<option value="">{{ $t('admin.packages.entitlement_placeholder') }}</option>
<option
v-for="ent in availableEntitlements"
:key="ent"
:value="ent"
:disabled="selectedEntitlements.includes(ent)"
>
{{ ent }}
</option>
</select>
</div>
</div>
<!-- Error Display -->
<div
v-if="errorMessage"
class="px-4 py-3 bg-red-900/40 border border-red-700 rounded-lg text-red-300 text-sm"
>
{{ errorMessage }}
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-700">
<button
class="px-4 py-2.5 text-sm font-medium text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
@click="$emit('close')"
>
{{ $t('admin.packages.cancel') }}
</button>
<button
class="px-4 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isFormValid"
@click="handleSave"
>
<span v-if="isSaving" class="flex items-center gap-2">
<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>
{{ $t('admin.packages.saving') }}
</span>
<span v-else>
{{ isEditing ? $t('admin.packages.save_changes') : $t('admin.packages.create') }}
</span>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAdminPackagesStore, AVAILABLE_ENTITLEMENTS } from '../../stores/adminPackages'
import type { SubscriptionTierItem, SubscriptionRulesModel } from '../../stores/adminPackages'
const props = defineProps<{
package?: SubscriptionTierItem | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const store = useAdminPackagesStore()
const isEditing = computed(() => !!props.package)
// ── Form State ──────────────────────────────────────────────────────────────
const form = ref({
name: '',
display_name: '',
type: 'private' as 'private' | 'corporate',
monthly_price: 0,
yearly_price: 0,
currency: 'EUR',
credit_price: null as number | null,
max_vehicles: 0,
max_garages: 0,
monthly_free_credits: 0,
is_public: true,
available_until: '' as string,
commission_rate_percent: 0,
referral_bonus_credits: 0,
})
const selectedEntitlements = ref<string[]>([])
const entitlementToAdd = ref('')
const isSaving = ref(false)
const errorMessage = ref<string | null>(null)
const availableEntitlements = computed(() => {
return AVAILABLE_ENTITLEMENTS.filter((e) => !selectedEntitlements.value.includes(e))
})
const isFormValid = computed(() => {
return form.value.name.trim().length >= 2
})
// ── Initialize form when editing ────────────────────────────────────────────
watch(
() => props.package,
(pkg) => {
if (pkg) {
const rules = pkg.rules
form.value.name = pkg.name
form.value.display_name = rules.display_name || ''
form.value.type = rules.type
form.value.monthly_price = rules.pricing.monthly_price
form.value.yearly_price = rules.pricing.yearly_price
form.value.currency = rules.pricing.currency
form.value.credit_price = rules.pricing.credit_price ?? null
form.value.max_vehicles = rules.allowances.max_vehicles
form.value.max_garages = rules.allowances.max_garages
form.value.monthly_free_credits = rules.allowances.monthly_free_credits
form.value.is_public = rules.lifecycle?.is_public ?? true
form.value.available_until = rules.lifecycle?.available_until
? rules.lifecycle.available_until.slice(0, 10)
: ''
form.value.commission_rate_percent = rules.affiliate.commission_rate_percent
form.value.referral_bonus_credits = rules.affiliate.referral_bonus_credits
selectedEntitlements.value = [...rules.entitlements]
} else {
resetForm()
}
},
{ immediate: true },
)
function resetForm() {
form.value = {
name: '',
display_name: '',
type: 'private',
monthly_price: 0,
yearly_price: 0,
currency: 'EUR',
credit_price: null,
max_vehicles: 0,
max_garages: 0,
monthly_free_credits: 0,
is_public: true,
available_until: '',
commission_rate_percent: 0,
referral_bonus_credits: 0,
}
selectedEntitlements.value = []
errorMessage.value = null
}
// ── Entitlement Chip Management ─────────────────────────────────────────────
function addEntitlement() {
const val = entitlementToAdd.value
if (val && !selectedEntitlements.value.includes(val)) {
selectedEntitlements.value.push(val)
}
entitlementToAdd.value = ''
}
function removeEntitlement(ent: string) {
selectedEntitlements.value = selectedEntitlements.value.filter((e) => e !== ent)
}
// ── Build Rules Object ──────────────────────────────────────────────────────
function buildRules(): SubscriptionRulesModel {
const rules: SubscriptionRulesModel = {
type: form.value.type,
pricing: {
monthly_price: form.value.monthly_price,
yearly_price: form.value.yearly_price,
currency: form.value.currency,
},
allowances: {
max_vehicles: form.value.max_vehicles,
max_garages: form.value.max_garages,
monthly_free_credits: form.value.monthly_free_credits,
},
entitlements: [...selectedEntitlements.value],
affiliate: {
commission_rate_percent: form.value.commission_rate_percent,
referral_bonus_credits: form.value.referral_bonus_credits,
},
}
if (form.value.display_name.trim()) {
rules.display_name = form.value.display_name.trim()
}
// Credit price
if (form.value.credit_price !== null && form.value.credit_price >= 0) {
rules.pricing.credit_price = form.value.credit_price
}
// Lifecycle
const lifecycle: Record<string, any> = {
is_public: form.value.is_public,
}
if (form.value.available_until) {
lifecycle.available_until = form.value.available_until
}
rules.lifecycle = lifecycle as any
return rules
}
// ── Save Handler ────────────────────────────────────────────────────────────
async function handleSave() {
if (!isFormValid.value) return
isSaving.value = true
errorMessage.value = null
try {
const rules = buildRules()
if (isEditing.value && props.package) {
await store.updatePackage(props.package.id, {
name: form.value.name,
rules,
})
} else {
// Create: do NOT send id — PostgreSQL auto-increments it
await store.createPackage({
name: form.value.name,
rules,
})
}
emit('saved')
} catch (err: any) {
errorMessage.value = err.response?.data?.detail || store.error || 'An error occurred'
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -180,6 +180,7 @@ const errorMessage = ref<string | null>(null)
interface CategoryOption {
id: number
name: string
code?: string
parent_id: number | null
}
@@ -208,14 +209,16 @@ async function fetchCategories() {
window.__allCostCategories = allCategories
} catch (err) {
console.error('[ComplexExpenseModal] Failed to load categories:', err)
// Fallback: provide basic categories
// Fallback: provide basic categories — IDs must match vehicle.cost_categories in DB
mainCategories.value = [
{ id: 1, name: 'Üzemanyag', parent_id: null },
{ id: 2, name: 'Szerviz / Karbantartás', parent_id: null },
{ id: 3, name: 'Parkolás / Útdíj', parent_id: null },
{ id: 1, name: 'Üzemanyag / Töltés', parent_id: null },
{ id: 2, name: 'Szerviz & Karbantartás', parent_id: null },
{ id: 3, name: 'Gumiabroncsok', parent_id: null },
{ id: 4, name: 'Biztosítás', parent_id: null },
{ id: 5, name: 'Adók / Illetékek', parent_id: null },
{ id: 6, name: 'Egyéb', parent_id: null },
{ id: 5, name: 'Adók', parent_id: null },
{ id: 6, name: 'Útdíj & Parkolás', parent_id: null },
{ id: 9, name: 'Ápolás & Kozmetika', parent_id: null },
{ id: 10, name: 'Egyéb', parent_id: null },
]
}
}
@@ -246,22 +249,23 @@ async function handleSubmit() {
let categoryId: number
if (props.isFeeMode) {
// Fee mode: pre-filled as FEES
// Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID
costType = 'fee'
categoryId = 3 // Parkolás / Útdíj
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
const feesCat = allCats.find((c: CategoryOption) => c.code === 'FEES' && c.parent_id === null)
categoryId = feesCat?.id || 6 // Fallback to ID 6 if lookup fails
} else {
// Complex mode: use selected sub-category or main category
costType = 'other'
categoryId = Number(form.subCategory || form.mainCategory) || 6
categoryId = Number(form.subCategory || form.mainCategory) || 10
}
const payload = {
asset_id: props.vehicle.id,
// organization_id is omitted — backend auto-resolves from asset
category_id: categoryId,
cost_type: costType,
amount_local: form.amount,
currency_local: 'HUF' as const,
amount_net: form.amount,
currency: 'HUF' as const,
date: new Date(form.date).toISOString(),
mileage_at_cost: null,
description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'),

View File

@@ -13,23 +13,13 @@
</div>
<div class="flex items-center gap-3">
<button
@click="handleAddNewClick"
:disabled="isCheckingQuota"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed"
@click="$emit('add-new')"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
>
<svg
v-if="isCheckingQuota"
class="h-4 w-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
Új jármű hozzáadása
</button>
</div>
</div>
@@ -70,7 +60,7 @@
:key="vehicle.id"
:vehicle="vehicle"
@click="openDetailView(vehicle)"
@edit="openEditDirect(vehicle)"
@edit="$emit('edit-vehicle', vehicle)"
@set-primary="setPrimaryVehicle"
/>
</div>
@@ -86,41 +76,17 @@
<h3 class="text-lg font-semibold text-slate-500">Még nincsenek járművek</h3>
<p class="mt-1 text-sm text-slate-400">Kattints az "Új jármű hozzáadása" gombra az első jármű felvételéhez.</p>
<button
@click="handleAddNewClick"
:disabled="isCheckingQuota"
class="mt-6 inline-flex items-center gap-2 rounded-xl bg-sf-accent px-6 py-3 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed"
@click="$emit('add-new')"
class="mt-6 inline-flex items-center gap-2 rounded-xl bg-sf-accent px-6 py-3 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
>
<svg
v-if="isCheckingQuota"
class="h-4 w-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
Új jármű hozzáadása
</button>
</div>
</template>
<!-- Vehicle Form Modal (Add) -->
<VehicleFormModal
:is-open="isAddFormOpen"
@close="isAddFormOpen = false"
@saved="onVehicleSaved"
/>
<!-- Vehicle Form Modal (Edit) -->
<VehicleFormModal
:is-open="isEditFormOpen"
:vehicle="editingVehicle"
@close="isEditFormOpen = false"
@saved="onVehicleEdited"
/>
<!-- Vehicle Detail Modal (read-only) -->
<VehicleDetailModal
:is-open="isDetailOpen"
@@ -129,8 +95,6 @@
@edit="openEditFromDetail"
@set-primary="setPrimaryVehicle"
/>
<!-- Task 3: Hidden trigger for direct edit from DashboardView -->
<div style="display: none">{{ triggerEditFromParent }}</div>
</div>
</template>
@@ -138,18 +102,16 @@
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import { useVehicleStore } from '../../stores/vehicle'
import api from '../../api/axios'
import VehicleFormModal from './VehicleFormModal.vue'
import VehicleDetailModal from '../vehicle/VehicleDetailModal.vue'
import VehicleCardStandard from '../vehicle/VehicleCardStandard.vue'
const props = defineProps<{
targetVehicleId?: string | null
editTargetVehicle?: VehicleData | null
}>()
const emit = defineEmits<{
'clear-edit-target': []
'add-new': []
'edit-vehicle': [vehicle: VehicleData]
}>()
const vehicleStore = useVehicleStore()
@@ -188,71 +150,12 @@ function openDetailView(vehicle: VehicleData) {
isDetailOpen.value = true
}
// ── Direct Edit (from card edit button) ──
function openEditDirect(vehicle: VehicleData) {
editingVehicle.value = vehicle ? { ...vehicle } : null
isEditFormOpen.value = true
}
// ── Bridge: Detail → Edit ──
// ── Bridge: Detail → Edit (emits to parent DashboardView) ──
async function openEditFromDetail(vehicle: VehicleData) {
isDetailOpen.value = false
// Use nextTick so detail modal closes before edit opens
await nextTick()
editingVehicle.value = vehicle ? { ...vehicle } : null
isEditFormOpen.value = true
}
// ── Modal state (Add) ──
const isAddFormOpen = ref(false)
const isCheckingQuota = 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 handleAddNewClick() {
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) {
// Block — show a prominent warning instead of opening the modal
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
}
// Quota OK — open the add form
isAddFormOpen.value = true
} catch (err: any) {
console.error('[PrivateVehicleManager] Quota check failed:', err)
// Fail open — if the API is unreachable, let the user proceed
isAddFormOpen.value = true
} finally {
isCheckingQuota.value = false
}
}
function onVehicleSaved() {
isAddFormOpen.value = false
// In real implementation, refresh the vehicle list from the store
}
// ── Modal state (Edit) ──
const isEditFormOpen = ref(false)
const editingVehicle = ref<VehicleData | null>(null)
function onVehicleEdited() {
isEditFormOpen.value = false
editingVehicle.value = null
// In real implementation, refresh the vehicle list from the store
emit('edit-vehicle', vehicle)
}
// ── Targeted scroll to vehicle card ──
@@ -276,24 +179,9 @@ watch(() => props.targetVehicleId, (newVal) => {
}
})
// ── Task 3: Watch for editTargetVehicle from parent (DashboardView) ──
const triggerEditFromParent = computed(() => {
if (props.editTargetVehicle) {
// Open the edit form directly with the vehicle data
editingVehicle.value = { ...props.editTargetVehicle } as VehicleData
isEditFormOpen.value = true
// Clear the prop so it doesn't re-trigger
emit('clear-edit-target')
}
return props.editTargetVehicle?.id || null
})
// ── Set Primary Vehicle ──
async function setPrimaryVehicle(vehicle: VehicleData) {
console.log('[PrivateVehicleManager] setPrimaryVehicle called for:', vehicle.id, vehicle.license_plate)
// TODO: Real API call — PUT /api/v1/assets/vehicles/{id} with { is_primary: true }
// This will also need to unset any other primary vehicle for this user.
// For now, just log and update local state optimistically.
vehicleStore.setPrimaryVehicle(vehicle.id)
}
</script>

View File

@@ -110,8 +110,9 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useCostStore } from '../../stores/cost'
import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle'
const props = defineProps<{
@@ -135,6 +136,26 @@ const form = reactive({
const errorMessage = ref<string | null>(null)
// ── Category Resolution ──
const fuelCategoryId = ref<number>(1) // Default fallback
async function resolveFuelCategory() {
try {
const res = await api.get('/dictionaries/cost-categories')
const allCats = res.data as Array<{ id: number; code?: string; name: string; parent_id: number | null }>
const fuelCat = allCats.find((c) => c.code === 'FUEL' && c.parent_id === null)
if (fuelCat) {
fuelCategoryId.value = fuelCat.id
}
} catch (err) {
console.warn('[SimpleFuelModal] Could not fetch categories, using fallback ID 1:', err)
}
}
onMounted(() => {
resolveFuelCategory()
})
const vehicleLabel = computed(() => {
if (!props.vehicle) return 'Nincs kiválasztva jármű'
const plate = props.vehicle.license_plate || ''
@@ -151,14 +172,13 @@ async function handleSubmit() {
errorMessage.value = null
// Build payload: cost_type = 'fuel', category auto-set to FUEL
// Build payload: category auto-set to FUEL (resolved dynamically by code)
// organization_id is omitted — backend auto-resolves from asset
const payload = {
asset_id: props.vehicle.id,
category_id: 1, // FUEL category (default)
cost_type: 'fuel',
amount_local: form.amount,
currency_local: 'HUF' as const,
category_id: fuelCategoryId.value, // FUEL category (resolved by code)
amount_net: form.amount,
currency: 'HUF' as const,
date: new Date(form.date).toISOString(),
mileage_at_cost: form.odometer > 0 ? form.odometer : null,
description: `Tankolás: ${form.quantity} L/kWh`,

File diff suppressed because it is too large Load Diff

View File

@@ -4,29 +4,15 @@
v-if="authStore.isAuthenticated"
@click.stop="isOpen = !isOpen"
:disabled="authStore.isLoading"
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
:class="orgButtonClass"
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer text-white/70 hover:bg-white/5 hover:text-white"
>
<!-- Loading spinner -->
<svg
v-if="authStore.isLoading"
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" />
<!-- Garage icon -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<!-- Icon -->
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="orgButtonState === 'create'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<span>{{ orgButtonLabel }}</span>
<!-- Chevron down when there are organizations -->
<span>{{ t('header.garageSelector') }}</span>
<!-- Chevron down -->
<svg
v-if="authStore.myOrganizations.length > 0 && orgButtonState === 'switch'"
class="w-3 h-3 transition-transform"
:class="{ 'rotate-180': isOpen }"
fill="none"
@@ -37,7 +23,7 @@
</svg>
</button>
<!-- Organization Dropdown -->
<!-- Garage Selector Dropdown -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="scale-95 opacity-0 -translate-y-2"
@@ -50,67 +36,79 @@
v-if="isOpen"
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- When on Company Garage: show "👤 Privát Garázs" back button -->
<div v-if="isOnCompanyGarage" class="py-1 border-b border-white/10">
<button
@click="goToPersonalDashboard"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-gray-200 transition-all duration-150 hover:bg-white/10 hover:text-white"
<!-- Private Garage (always first) -->
<button
@click="goToPersonalDashboard"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
:class="!isOnCompanyGarage ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
<span>👤 {{ t('header.privateGarage') }}</span>
<svg
v-if="!isOnCompanyGarage"
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg class="w-4 h-4 text-gray-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>👤 {{ t('header.privateGarage') }}</span>
</button>
</div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Organization list -->
<div v-if="filteredOrganizations.length > 0" class="py-1 max-h-60 overflow-y-auto">
<router-link
v-for="org in filteredOrganizations"
:key="org.organization_id"
:to="'/organization/' + org.organization_id"
@click="isOpen = false"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
:class="isActiveOrg(org) ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
>
<!-- Org icon -->
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
<span class="truncate">{{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}</span>
<!-- Active checkmark -->
<svg
v-if="isActiveOrg(org)"
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<!-- Company Garages (if any) -->
<template v-if="companyOrganizations.length > 0">
<div class="border-t border-white/10 my-1"></div>
<div class="px-4 py-1.5 text-xs text-white/40 uppercase tracking-wider font-semibold">
{{ t('header.myCompanies') }}
</div>
<div class="max-h-60 overflow-y-auto">
<router-link
v-for="org in companyOrganizations"
:key="org.organization_id"
:to="'/organization/' + org.organization_id"
@click="isOpen = false"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
:class="isActiveOrg(org) ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</router-link>
</div>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
<span class="truncate">{{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}</span>
<svg
v-if="isActiveOrg(org)"
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</router-link>
</div>
</template>
<!-- Empty state when no orgs exist -->
<div v-if="authStore.myOrganizations.length === 0" class="py-4 px-4 text-center text-white/50 text-sm">
{{ t('header.noCompanies') }}
</div>
<!-- Divider before "Create new" -->
<hr class="border-gray-700 my-1" />
<!-- Create new organization -->
<div class="py-1">
<button
@click="goToOnboard"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>{{ t('header.newCompany') }}</span>
</button>
</div>
<!-- Separator + PLG actions -->
<div class="border-t border-white/10 my-1"></div>
<button
@click="handleCreateCompany"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>{{ t('header.createCompany') }}</span>
</button>
<button
@click="handleJoinCompany"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7l3-3m0 0l3-3m-3 3l-3-3m3 3l3 3M8 21l-3 3m0 0l-3 3m3-3l-3-3m3 3l3-3M4 4l16 16" />
</svg>
<span>{{ t('header.joinCompany') }}</span>
</button>
</div>
</Transition>
</div>
@@ -136,7 +134,15 @@ onMounted(async () => {
if (authStore.myOrganizations.length === 0 && authStore.isAuthenticated) {
await authStore.fetchMyOrganizations()
}
console.log('🔍 [HeaderCompanySwitcher] Available companies:', authStore.myOrganizations)
console.log('🔍 [HeaderCompanySwitcher] All orgs:', authStore.myOrganizations)
console.log('🔍 [HeaderCompanySwitcher] Company orgs (filtered):', companyOrganizations.value)
})
// ── Filter: only real companies (exclude individual/private orgs) ──
const companyOrganizations = computed(() => {
return authStore.myOrganizations.filter(
(org) => org.org_type && org.org_type !== 'individual'
)
})
// ── Route-based detection ─────────────────────────────────────────
@@ -144,55 +150,6 @@ const isOnCompanyGarage = computed(() => {
return route.path.startsWith('/organization/')
})
// ── Current org ID from route (when on company garage) ────────────
const currentOrgId = computed(() => {
if (!isOnCompanyGarage.value) return null
return Number(route.params.id) || null
})
// ── Filtered organizations: exclude the current one when on garage ─
const filteredOrganizations = computed(() => {
const orgs = authStore.myOrganizations
if (isOnCompanyGarage.value && currentOrgId.value) {
return orgs.filter((o) => o.organization_id !== currentOrgId.value)
}
return orgs
})
// ── Organization Button State ──────────────────────────────────────
const orgButtonState = computed(() => {
if (!authStore.isAuthenticated) return 'create'
if (isOnCompanyGarage.value) return 'on-garage'
if (authStore.isCorporateMode) return 'corporate'
if (authStore.hasBusinessOrganization) return 'switch'
return 'create'
})
const orgButtonLabel = computed(() => {
switch (orgButtonState.value) {
case 'create':
return t('header.newCompany')
case 'switch': {
const count = authStore.myOrganizations.length
if (count === 1) return t('header.myCompany')
return t('header.myCompanies')
}
case 'corporate':
return t('header.personalGarage')
case 'on-garage':
return t('header.privateGarage')
default:
return t('header.company')
}
})
const orgButtonClass = computed(() => {
if (orgButtonState.value === 'corporate') {
return 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/20 hover:bg-yellow-500/25 hover:text-yellow-300'
}
return 'text-white/70 hover:bg-white/5 hover:text-white'
})
// ── Check if org is the active one ─────────────────────────────────
function isActiveOrg(org: OrganizationItem): boolean {
return authStore.user?.active_organization_id === org.organization_id
@@ -205,12 +162,20 @@ async function goToPersonalDashboard() {
router.push('/dashboard')
}
// ── Navigate to onboard ───────────────────────────────────────────
function goToOnboard() {
// ── PLG: Create Company ───────────────────────────────────────────
function handleCreateCompany() {
isOpen.value = false
console.log('🔧 [PLG] Create Company clicked — modal/onboarding coming soon')
router.push('/company/onboard')
}
// ── PLG: Join Company ─────────────────────────────────────────────
function handleJoinCompany() {
isOpen.value = false
console.log('🔧 [PLG] Join Company clicked — join flow coming soon')
alert('🔗 Join Company — This feature is coming soon!')
}
// ── Close dropdown on outside click ───────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement

View File

@@ -50,6 +50,28 @@
</svg>
{{ t('header.profile') }}
</button>
<!-- Admin Center only for admin/superadmin roles -->
<button
v-if="isAdmin"
@click="goToAdmin"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-yellow-400/80 transition-all duration-150 hover:bg-white/5 hover:text-yellow-300"
>
<svg
class="h-4 w-4 text-yellow-400/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
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>
{{ t('header.adminCenter') }}
</button>
</div>
<!-- Divider -->
@@ -120,12 +142,26 @@ const initials = computed(() => {
return '?'
})
// ── Admin check: only admin/superadmin/region_admin/country_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)
})
// ── Navigate to Profile ────────────────────────────────────────────
function goToProfile() {
isOpen.value = false
router.push('/profile')
}
// ── Navigate to Admin Center ───────────────────────────────────────
function goToAdmin() {
isOpen.value = false
router.push('/admin')
}
// ── Logout handler ─────────────────────────────────────────────────
function handleLogout() {
isOpen.value = false

View File

@@ -0,0 +1,318 @@
<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="handleBackdropClick"
>
<div
class="relative w-full max-w-lg mx-4 rounded-2xl bg-[#04151F] border border-white/10 shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h3 class="text-lg font-semibold text-white">
{{ isEditing ? t('company.settingsModalTitle') : t('company.companyDataTitle') }}
</h3>
<button
@click="$emit('close')"
class="p-1 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-8 w-8 text-[#00E5A0]" 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>
</div>
<template v-else>
<!-- Company Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsName') }}</label>
<input
v-if="isEditing"
v-model="form.name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.name || '—' }}</p>
</div>
<!-- Full Company Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsFullName') }}</label>
<input
v-if="isEditing"
v-model="form.full_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.full_name || '—' }}</p>
</div>
<!-- Display Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsDisplayName') }}</label>
<input
v-if="isEditing"
v-model="form.display_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.display_name || '—' }}</p>
</div>
<!-- Tax Number (DISABLED in edit mode too) -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsTaxNumber') }}</label>
<input
v-if="isEditing"
v-model="form.tax_number"
type="text"
disabled
class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white/50 text-sm cursor-not-allowed"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.tax_number || '—' }}</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') || 'Address' }}</p>
<!-- ZIP Code -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressZip') }}</label>
<input
v-if="isEditing"
v-model="form.address_zip"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_zip || '—' }}</p>
</div>
<!-- City -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressCity') }}</label>
<input
v-if="isEditing"
v-model="form.address_city"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_city || '—' }}</p>
</div>
<!-- Street -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressStreet') }}</label>
<input
v-if="isEditing"
v-model="form.address_street_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_street_name || '—' }}</p>
</div>
<!-- House Number -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressHouseNumber') }}</label>
<input
v-if="isEditing"
v-model="form.address_house_number"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_house_number || '—' }}</p>
</div>
</template>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10">
<button
@click="isEditing ? cancelEdit() : $emit('close')"
class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors"
>
{{ isEditing ? t('profile.cancel') : t('company.close') || t('header.close') }}
</button>
<!-- Edit button (read-only mode) -->
<button
v-if="!isEditing"
@click="startEdit"
class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm transition-colors"
>
{{ t('profile.edit') }}
</button>
<!-- Save button (edit mode) -->
<button
v-else
@click="handleSave"
:disabled="isSaving"
class="px-4 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="isSaving" class="flex items-center gap-2">
<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>
{{ t('company.submitting') }}
</span>
<span v-else>{{ t('profile.save') }}</span>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/axios'
import type { OrganizationItem } from '@/types/organization'
const { t } = useI18n()
const route = useRoute()
const authStore = useAuthStore()
const emit = defineEmits<{
close: []
saved: []
}>()
// ── State ───────────────────────────────────────────────────────────────────
const isLoading = ref(false)
const isSaving = ref(false)
const isEditing = ref(false)
const org = ref<OrganizationItem | null>(null)
const form = reactive({
name: '',
full_name: '',
display_name: '',
tax_number: '',
address_zip: '',
address_city: '',
address_street_name: '',
address_house_number: '',
})
// ── Lifecycle ───────────────────────────────────────────────────────────────
onMounted(async () => {
const orgId = Number(route.params.id)
if (!orgId) return
// Find org from store
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (found) {
org.value = found
prefillForm(found)
} else {
// Try to fetch fresh data
isLoading.value = true
try {
await authStore.fetchMyOrganizations()
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (refound) {
org.value = refound
prefillForm(refound)
}
} catch (err) {
console.error('Failed to load organization:', err)
} finally {
isLoading.value = false
}
}
})
// ── Helpers ─────────────────────────────────────────────────────────────────
function prefillForm(data: OrganizationItem) {
form.name = data.name || ''
form.full_name = data.full_name || ''
form.display_name = data.display_name || ''
form.tax_number = data.tax_number || ''
// Address fields may come from a nested address object or flat fields
// We use type-safe access with optional chaining
form.address_zip = (data as any).address_zip || ''
form.address_city = (data as any).address_city || ''
form.address_street_name = (data as any).address_street_name || ''
form.address_house_number = (data as any).address_house_number || ''
}
function startEdit() {
// Re-populate form from current org data
if (org.value) {
prefillForm(org.value)
}
isEditing.value = true
}
function cancelEdit() {
isEditing.value = false
// Reset form to original values
if (org.value) {
prefillForm(org.value)
}
}
function handleBackdropClick() {
if (isEditing.value) {
cancelEdit()
} else {
emit('close')
}
}
// ── Save Handler ────────────────────────────────────────────────────────────
async function handleSave() {
const orgId = Number(route.params.id)
if (!orgId) return
isSaving.value = true
try {
// Build payload with only non-empty fields
const payload: Record<string, string> = {}
for (const [key, value] of Object.entries(form)) {
if (value.trim()) {
payload[key] = value.trim()
}
}
await api.patch(`/organizations/${orgId}`, payload)
// Refresh org data in store
await authStore.fetchMyOrganizations()
// Update local org reference
const updated = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (updated) {
org.value = updated
}
isEditing.value = false
emit('saved')
} catch (err: any) {
console.error('Failed to save organization data:', err)
alert(t('company.settingsSaveError'))
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,251 @@
<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div
class="relative w-full max-w-lg mx-4 rounded-2xl bg-[#04151F] border border-white/10 shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h3 class="text-lg font-semibold text-white">{{ t('company.settingsModalTitle') }}</h3>
<button
@click="$emit('close')"
class="p-1 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-8 w-8 text-[#00E5A0]" 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>
</div>
<template v-else>
<!-- Company Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsName') }}</label>
<input
v-model="form.name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
:placeholder="org?.name || ''"
/>
</div>
<!-- Full Company Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsFullName') }}</label>
<input
v-model="form.full_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
:placeholder="org?.full_name || ''"
/>
</div>
<!-- Display Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsDisplayName') }}</label>
<input
v-model="form.display_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
:placeholder="org?.display_name || ''"
/>
</div>
<!-- Tax Number -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsTaxNumber') }}</label>
<input
v-model="form.tax_number"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
:placeholder="org?.tax_number || ''"
/>
</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>
<!-- ZIP Code -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressZip') }}</label>
<input
v-model="form.address_zip"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
</div>
<!-- City -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressCity') }}</label>
<input
v-model="form.address_city"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
</div>
<!-- Street -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressStreet') }}</label>
<input
v-model="form.address_street_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
</div>
<!-- House Number -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressHouseNumber') }}</label>
<input
v-model="form.address_house_number"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
</div>
</template>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10">
<button
@click="$emit('close')"
class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors"
>
{{ t('company.cancel') }}
</button>
<button
@click="handleSave"
:disabled="isSaving"
class="px-4 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="isSaving" class="flex items-center gap-2">
<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>
{{ t('company.submitting') }}
</span>
<span v-else>{{ t('profile.save') }}</span>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/axios'
import type { OrganizationItem } from '@/types/organization'
const { t } = useI18n()
const route = useRoute()
const authStore = useAuthStore()
const emit = defineEmits<{
close: []
saved: []
}>()
// ── State ───────────────────────────────────────────────────────────────────
const isLoading = ref(false)
const isSaving = ref(false)
const org = ref<OrganizationItem | null>(null)
const form = reactive({
name: '',
full_name: '',
display_name: '',
tax_number: '',
address_zip: '',
address_city: '',
address_street_name: '',
address_house_number: '',
})
// ── Lifecycle ───────────────────────────────────────────────────────────────
onMounted(async () => {
const orgId = Number(route.params.id)
if (!orgId) return
// Find org from store
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (found) {
org.value = found
// 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 || ''
} else {
// Try to fetch fresh data
isLoading.value = true
try {
await authStore.fetchMyOrganizations()
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (refound) {
org.value = refound
form.name = refound.name || ''
form.full_name = refound.full_name || ''
form.display_name = refound.display_name || ''
form.tax_number = refound.tax_number || ''
}
} catch (err) {
console.error('Failed to load organization:', err)
} finally {
isLoading.value = false
}
}
})
// ── Save Handler ────────────────────────────────────────────────────────────
async function handleSave() {
const orgId = Number(route.params.id)
if (!orgId) return
isSaving.value = true
try {
// Build payload with only non-empty fields
const payload: Record<string, string> = {}
for (const [key, value] of Object.entries(form)) {
if (value.trim()) {
payload[key] = value.trim()
}
}
await api.patch(`/organizations/${orgId}`, payload)
// Refresh org data in store
await authStore.fetchMyOrganizations()
emit('saved')
} catch (err: any) {
console.error('Failed to save organization settings:', err)
alert(t('company.settingsSaveError'))
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -170,168 +170,144 @@
<!-- TAB 2: Pénzügyek / TCO -->
<div v-if="activeTab === 'finance'" class="space-y-5">
<!-- Annual Total Cost Hero -->
<div class="rounded-2xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-6 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Éves Összköltség (TCO)</p>
<p class="text-4xl font-extrabold text-slate-800">1,245,000 Ft</p>
<p class="text-xs text-slate-400 mt-1">~ 3,411 Ft / nap</p>
<!-- Loading State -->
<div
v-if="costsLoading"
class="flex items-center justify-center py-12"
>
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div>
<!-- Task 5: Freemium Logic -->
<!-- Free users: simple clean table -->
<template v-if="!isPremium">
<div class="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-slate-100 border-b border-slate-200">
<th class="text-left px-4 py-3 font-semibold text-slate-600">Költségtípus</th>
<th class="text-right px-4 py-3 font-semibold text-slate-600">Összeg</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Biztosítás (KGFB + Casco)</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">320,000 Ft</td>
</tr>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Gépjárműadó</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">45,000 Ft</td>
</tr>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Műszaki vizsga</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">18,000 Ft</td>
</tr>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Tankolás (üzemanyag)</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">520,000 Ft</td>
</tr>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Szerviz & Karbantartás</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">210,000 Ft</td>
</tr>
<tr>
<td class="px-4 py-2.5 text-slate-600">Parkolás & Útdíj</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">132,000 Ft</td>
</tr>
</tbody>
<tfoot>
<tr class="bg-slate-100 border-t border-slate-200">
<td class="px-4 py-3 font-bold text-slate-700">Összesen</td>
<td class="px-4 py-3 text-right font-bold text-slate-800">1,245,000 Ft</td>
</tr>
</tfoot>
</table>
<template v-if="!costsLoading">
<!-- Annual Total Cost Hero -->
<div class="rounded-2xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-6 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Éves Összköltség (TCO)</p>
<p class="text-4xl font-extrabold text-slate-800">{{ totalCostDisplay }}</p>
<p class="text-xs text-slate-400 mt-1">~ {{ dailyCostDisplay }} / nap</p>
</div>
<!-- Real Costs Table (Free users) -->
<template v-if="!isPremium">
<div v-if="vehicleCosts.length === 0" class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center">
<p class="text-sm text-slate-500">Nincs rögzített költség</p>
</div>
<div v-else class="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-slate-100 border-b border-slate-200">
<th class="text-left px-4 py-3 font-semibold text-slate-600">Dátum</th>
<th class="text-left px-4 py-3 font-semibold text-slate-600">Kategória</th>
<th class="text-right px-4 py-3 font-semibold text-slate-600">Összeg</th>
</tr>
</thead>
<tbody>
<tr
v-for="cost in vehicleCosts"
:key="cost.id"
class="border-b border-slate-200 last:border-b-0"
>
<td class="px-4 py-2.5 text-slate-600">{{ formatDate(cost.occurrence_date) }}</td>
<td class="px-4 py-2.5 text-slate-600">{{ cost.category_name || cost.category }}</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">{{ formatAmount(cost.amount) }}</td>
</tr>
</tbody>
<tfoot>
<tr class="bg-slate-100 border-t border-slate-200">
<td colspan="2" class="px-4 py-3 font-bold text-slate-700">Összesen</td>
<td class="px-4 py-3 text-right font-bold text-slate-800">{{ totalCostDisplay }}</td>
</tr>
</tfoot>
</table>
</div>
</template>
<!-- Premium users: detailed breakdown with charts -->
<template v-if="isPremium">
<div v-if="vehicleCosts.length === 0" class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center">
<p class="text-sm text-slate-500">Nincs rögzített költség</p>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Fixed Costs -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center gap-2 mb-3">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600 text-sm">🔒</span>
<p class="text-sm font-bold text-slate-700">Állandó költségek</p>
</div>
<div class="space-y-2.5">
<div
v-for="cost in fixedCosts"
:key="cost.id"
class="flex items-center justify-between"
>
<span class="text-xs text-slate-500">{{ cost.category_name || cost.category }}</span>
<span class="text-sm font-bold text-slate-700">{{ formatAmount(cost.amount) }}</span>
</div>
<div v-if="fixedCosts.length === 0" class="text-xs text-slate-400 text-center py-2">Nincs állandó költség</div>
<div class="border-t border-slate-200 pt-2 flex items-center justify-between">
<span class="text-xs font-bold text-slate-600">Állandó összesen</span>
<span class="text-sm font-bold text-slate-800">{{ formatAmount(fixedTotal) }}</span>
</div>
</div>
</div>
<!-- Running Costs -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center gap-2 mb-3">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600 text-sm"></span>
<p class="text-sm font-bold text-slate-700">Folyó költségek</p>
</div>
<div class="space-y-2.5">
<div
v-for="cost in runningCosts"
:key="cost.id"
class="flex items-center justify-between"
>
<span class="text-xs text-slate-500">{{ cost.category_name || cost.category }}</span>
<span class="text-sm font-bold text-slate-700">{{ formatAmount(cost.amount) }}</span>
</div>
<div v-if="runningCosts.length === 0" class="text-xs text-slate-400 text-center py-2">Nincs folyó költség</div>
<div class="border-t border-slate-200 pt-2 flex items-center justify-between">
<span class="text-xs font-bold text-slate-600">Folyó összesen</span>
<span class="text-sm font-bold text-slate-800">{{ formatAmount(runningTotal) }}</span>
</div>
</div>
</div>
</div>
<!-- Premium: Cost distribution chart -->
<div class="rounded-xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-5">
<p class="text-sm font-bold text-slate-700 mb-3">Költségeloszlás (éves)</p>
<div class="space-y-2">
<div
v-for="cat in costDistribution"
:key="cat.label"
>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>{{ cat.label }}</span>
<span>{{ cat.percent }}%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="cat.color"
:style="{ width: cat.percent + '%' }"
/>
</div>
</div>
<div v-if="costDistribution.length === 0" class="text-xs text-slate-400 text-center py-2">Nincs adat</div>
</div>
</div>
</template>
<!-- TCO per km hint (visible to all) -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500">
<span class="font-semibold text-slate-700">TCO / km:</span> ~ {{ tcoPerKmDisplay }}
<span class="text-slate-300 mx-2">|</span>
<span class="font-semibold text-slate-700">Havi átlag:</span> ~ {{ monthlyAverageDisplay }}
</p>
</div>
</template>
<!-- Premium users: detailed breakdown with charts -->
<template v-if="isPremium">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Fixed Costs -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center gap-2 mb-3">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600 text-sm">🔒</span>
<p class="text-sm font-bold text-slate-700">Állandó költségek</p>
</div>
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Biztosítás (KGFB + Casco)</span>
<span class="text-sm font-bold text-slate-700">320,000 Ft</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Gépjárműadó</span>
<span class="text-sm font-bold text-slate-700">45,000 Ft</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Műszaki vizsga</span>
<span class="text-sm font-bold text-slate-700">18,000 Ft</span>
</div>
<div class="border-t border-slate-200 pt-2 flex items-center justify-between">
<span class="text-xs font-bold text-slate-600">Állandó összesen</span>
<span class="text-sm font-bold text-slate-800">383,000 Ft</span>
</div>
</div>
</div>
<!-- Running Costs -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center gap-2 mb-3">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600 text-sm"></span>
<p class="text-sm font-bold text-slate-700">Folyó költségek</p>
</div>
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Tankolás (üzemanyag)</span>
<span class="text-sm font-bold text-slate-700">520,000 Ft</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Szerviz & Karbantartás</span>
<span class="text-sm font-bold text-slate-700">210,000 Ft</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Parkolás & Útdíj</span>
<span class="text-sm font-bold text-slate-700">132,000 Ft</span>
</div>
<div class="border-t border-slate-200 pt-2 flex items-center justify-between">
<span class="text-xs font-bold text-slate-600">Folyó összesen</span>
<span class="text-sm font-bold text-slate-800">862,000 Ft</span>
</div>
</div>
</div>
</div>
<!-- Premium: Cost distribution chart placeholder -->
<div class="rounded-xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-5">
<p class="text-sm font-bold text-slate-700 mb-3">Költségeloszlás (éves)</p>
<div class="space-y-2">
<div>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>Biztosítás</span>
<span>26%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div class="h-full rounded-full bg-blue-500" style="width: 26%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>Üzemanyag</span>
<span>42%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div class="h-full rounded-full bg-emerald-500" style="width: 42%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>Szerviz</span>
<span>17%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div class="h-full rounded-full bg-amber-500" style="width: 17%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>Egyéb</span>
<span>15%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div class="h-full rounded-full bg-purple-500" style="width: 15%"></div>
</div>
</div>
</div>
</div>
</template>
<!-- TCO per km hint (visible to all) -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500">
<span class="font-semibold text-slate-700">TCO / km:</span> ~ 62 Ft/km
<span class="text-slate-300 mx-2">|</span>
<span class="font-semibold text-slate-700">Havi átlag:</span> ~ 103,750 Ft
</p>
</div>
</div>
<!-- TAB 3: Figyelmeztetések & Időpontok -->
@@ -448,6 +424,7 @@
import { ref, computed, watch } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import { useAuthStore } from '../../stores/auth'
import api from '../../api/axios'
import VehiclePlateBadge from './VehiclePlateBadge.vue'
const props = defineProps<{
@@ -476,9 +453,114 @@ const tabs = [
watch(() => props.isOpen, (newVal) => {
if (newVal) {
activeTab.value = 'basics'
vehicleCosts.value = []
costsLoading.value = false
}
})
// ── Financials: Vehicle Costs from API ──
interface CostItem {
id: string | number
/** Backend returns `date` (ISO datetime), mapped to `occurrence_date` for display */
occurrence_date: string
/** Backend returns `category_name` from CostCategory relationship */
category: string
category_name?: string
/** Backend returns `category_code` from CostCategory relationship */
category_code?: string
/** Backend returns `amount_net` (Decimal) — the main monetary value */
amount: number
/** Backend returns `currency` (e.g. 'HUF') */
currency?: string
/** Backend returns `description` extracted from data JSONB */
description?: string
/** Backend returns `mileage_at_cost` extracted from data JSONB */
mileage_at_cost?: number
/** Raw backend fields kept for reference */
amount_net?: number
currency_raw?: string
date?: string
data?: Record<string, any>
}
const vehicleCosts = ref<CostItem[]>([])
const costsLoading = ref(false)
/**
* Map a raw backend AssetCostResponse item to the frontend CostItem shape.
* Backend schema (AssetCostResponse) now uses:
* amount_net, currency, date, category_name, category_code, description, mileage_at_cost
*/
function mapBackendCost(raw: any): CostItem {
return {
id: raw.id,
occurrence_date: raw.date || raw.occurrence_date || '',
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),
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_net: raw.amount_net,
currency_raw: raw.currency,
date: raw.date,
data: raw.data,
}
}
/**
* Fetch costs for the current vehicle when the 'finance' tab is activated.
* GET /api/v1/assets/{vehicle.id}/costs
*/
watch(() => activeTab.value, async (tab) => {
if (tab === 'finance' && props.vehicle?.id) {
costsLoading.value = true
try {
const res = await api.get(`/assets/${props.vehicle.id}/costs`)
const rawData = (res.data as any[]) || []
vehicleCosts.value = rawData.map(mapBackendCost)
} catch (err: any) {
console.error('[VehicleDetailModal] Failed to fetch costs:', err)
vehicleCosts.value = []
} finally {
costsLoading.value = false
}
}
})
/**
* Also fetch costs when the vehicle prop changes while on the finance tab.
*/
watch(() => props.vehicle, (newVehicle) => {
if (activeTab.value === 'finance' && newVehicle?.id) {
costsLoading.value = true
api.get(`/assets/${newVehicle.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 '—'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return '—'
return d.toLocaleDateString('hu-HU', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
// ── Format currency helper ──
function formatAmount(amount: number): string {
if (amount === undefined || amount === null) return '—'
if (isNaN(amount)) return '—'
return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: 'HUF', maximumFractionDigits: 0 }).format(amount)
}
// ── Task 4: Trust Score computed helpers ──
const trustScorePercent = computed(() => {
const v = props.vehicle
@@ -543,6 +625,100 @@ const hpDisplay = computed(() => {
if (!kw) return '—'
return `${Math.round(kw * 1.341)} LE`
})
// ── Financial computed helpers ──
const totalCost = computed(() => {
return vehicleCosts.value.reduce((sum, c) => {
const amt = Number(c.amount)
return sum + (isNaN(amt) ? 0 : amt)
}, 0)
})
const totalCostDisplay = computed(() => {
return formatAmount(totalCost.value)
})
const dailyCostDisplay = computed(() => {
if (!totalCost.value || totalCost.value === 0) return '0 Ft'
const daily = totalCost.value / 365
return formatAmount(daily)
})
const monthlyAverageDisplay = computed(() => {
if (!totalCost.value || totalCost.value === 0) return '0 Ft'
const monthly = totalCost.value / 12
return formatAmount(monthly)
})
const tcoPerKmDisplay = computed(() => {
const mileage = props.vehicle?.current_mileage || props.vehicle?.mileage
if (!mileage || mileage === 0 || !totalCost.value || totalCost.value === 0) return '—'
const perKm = totalCost.value / mileage
return formatAmount(perKm) + '/km'
})
// ── Premium breakdown: fixed vs running costs ──
const fixedCategories = ['insurance', 'tax', 'inspection', 'Biztosítás', 'Gépjárműadó', 'Műszaki vizsga', 'Adó', 'Kötelező']
const fixedCosts = computed(() => {
return vehicleCosts.value.filter(c => {
const cat = (c.category || '').toLowerCase()
const name = (c.category_name || '').toLowerCase()
return fixedCategories.some(fc => cat.includes(fc.toLowerCase()) || name.includes(fc.toLowerCase()))
})
})
const runningCosts = computed(() => {
return vehicleCosts.value.filter(c => {
const cat = (c.category || '').toLowerCase()
const name = (c.category_name || '').toLowerCase()
return !fixedCategories.some(fc => cat.includes(fc.toLowerCase()) || name.includes(fc.toLowerCase()))
})
})
const fixedTotal = computed(() => fixedCosts.value.reduce((sum, c) => {
const amt = Number(c.amount)
return sum + (isNaN(amt) ? 0 : amt)
}, 0))
const runningTotal = computed(() => runningCosts.value.reduce((sum, c) => {
const amt = Number(c.amount)
return sum + (isNaN(amt) ? 0 : amt)
}, 0))
// ── Cost distribution for premium chart ──
const chartColors = [
'bg-blue-500',
'bg-emerald-500',
'bg-amber-500',
'bg-purple-500',
'bg-rose-500',
'bg-cyan-500',
'bg-orange-500',
'bg-teal-500',
]
interface DistributionItem {
label: string
percent: number
color: string
}
const costDistribution = computed<DistributionItem[]>(() => {
if (!totalCost.value || totalCost.value === 0) return []
// Group by category_name or category
const grouped: Record<string, number> = {}
for (const c of vehicleCosts.value) {
const key = c.category_name || c.category || 'Egyéb'
const amt = Number(c.amount)
grouped[key] = (grouped[key] || 0) + (isNaN(amt) ? 0 : amt)
}
return Object.entries(grouped)
.map(([label, amount], idx) => ({
label,
percent: Math.round((amount / totalCost.value) * 100),
color: chartColors[idx % chartColors.length],
}))
.sort((a, b) => b.percent - a.percent)
})
</script>
<style scoped>