admin frontend elkezdése, járművek tisztázása, frontend fejleszts
This commit is contained in:
@@ -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>
|
||||
|
||||
81
frontend/src/components/ModeSwitcher.vue
Normal file
81
frontend/src/components/ModeSwitcher.vue
Normal 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>
|
||||
482
frontend/src/components/admin/PackageEditModal.vue
Normal file
482
frontend/src/components/admin/PackageEditModal.vue
Normal 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>
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
318
frontend/src/components/organization/CompanyDataModal.vue
Normal file
318
frontend/src/components/organization/CompanyDataModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user