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>
|
||||
|
||||
@@ -33,6 +33,10 @@ export default {
|
||||
close: 'Close',
|
||||
noCompanies: 'No companies yet',
|
||||
newCompany: '➕ New Company',
|
||||
joinCompany: '🔗 Join Company',
|
||||
createCompany: '➕ Create Company',
|
||||
actions: 'Actions',
|
||||
garageSelector: 'Garage Selector',
|
||||
myCompany: '🏢 My Company',
|
||||
myCompanies: '🏢 My Companies',
|
||||
personalGarage: '👤 Personal Garage',
|
||||
@@ -50,6 +54,8 @@ export default {
|
||||
regionSettings: 'Region Settings',
|
||||
regionDescription: 'Date, time & currency format',
|
||||
dashboard: 'Dashboard',
|
||||
menu: 'Menu',
|
||||
adminCenter: '🔐 Admin Center',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Loading...',
|
||||
@@ -126,6 +132,20 @@ export default {
|
||||
registerFailed: 'Registration failed.',
|
||||
resendFailed: 'Resend failed. Please try again later.',
|
||||
forgotFailed: 'An error occurred while sending the request. Please try again.',
|
||||
// Account Restore (30-day)
|
||||
restoreLink: 'Restore deleted account',
|
||||
restoreTitle: 'Restore Deleted Account',
|
||||
restoreStep1: 'Enter your email address to request a restoration code.',
|
||||
restoreStep2: 'Enter the received code and your new password.',
|
||||
restoreEmailLabel: 'Email Address',
|
||||
restoreSendCode: 'Send Restoration Code',
|
||||
restoreOtpLabel: 'Restoration Code (6 digits)',
|
||||
restoreNewPassword: 'New Password',
|
||||
restoreVerifyButton: 'Restore Account',
|
||||
restoreSuccess: 'Your account has been successfully restored! Auto-logging in...',
|
||||
restoreError: 'Restoration failed. Please try again.',
|
||||
restoreNotFound: 'No deleted account found with this email address.',
|
||||
restoreCodeSent: 'The restoration code has been sent to your email!',
|
||||
},
|
||||
profile: {
|
||||
title: 'Profile Settings',
|
||||
@@ -205,6 +225,18 @@ export default {
|
||||
currencyHUF: 'HUF (Ft)',
|
||||
currencyEUR: 'EUR (€)',
|
||||
currencyUSD: 'USD ($)',
|
||||
// Delete Account
|
||||
deleteAccount: 'Delete Account',
|
||||
deleteAccountConfirm: 'Are you sure you want to delete your account?',
|
||||
deleteAccountWarning: 'Are you sure you want to delete your account? This action is permanent and cannot be undone.',
|
||||
deleteAccountReason: 'Why do you want to delete your account? (optional)',
|
||||
deleteAccountButton: 'Yes, delete my account',
|
||||
deleteAccountCancel: 'Cancel',
|
||||
deleteSuccess: 'Your account has been successfully deleted. Redirecting...',
|
||||
deleteError: 'An error occurred while deleting your account.',
|
||||
// Last Admin Warning
|
||||
lastAdminWarningTitle: '⚠️ Last Admin Warning',
|
||||
lastAdminWarning: 'Warning! You are the last active administrator of your company. Deleting your account will put company data and fleet into passive (orphan) state!',
|
||||
// Back
|
||||
backToGarage: 'Back to Garage',
|
||||
// Diagnostic
|
||||
@@ -283,6 +315,23 @@ export default {
|
||||
quickActionsTitle: 'Quick Actions',
|
||||
quickNewWaybill: 'New Waybill',
|
||||
quickReportIssue: 'Report Issue',
|
||||
// Organization Settings
|
||||
companyDataTitle: 'Company Data',
|
||||
companyDataMenu: 'Company Data',
|
||||
close: 'Close',
|
||||
settingsTitle: 'Company Settings',
|
||||
settingsDescription: 'Edit company details',
|
||||
settingsModalTitle: 'Edit Company Details',
|
||||
settingsName: 'Company Name (short)',
|
||||
settingsFullName: 'Full Company Name',
|
||||
settingsTaxNumber: 'Tax Number',
|
||||
settingsDisplayName: 'Display Name',
|
||||
settingsAddressZip: 'ZIP Code',
|
||||
settingsAddressCity: 'City',
|
||||
settingsAddressStreet: 'Street',
|
||||
settingsAddressHouseNumber: 'House Number',
|
||||
settingsSaveSuccess: 'Company details updated successfully!',
|
||||
settingsSaveError: 'Error saving company details.',
|
||||
// CompanyOnboardingView
|
||||
onboardingTitle: 'Company Registration — 3 simple steps',
|
||||
stepLegal: 'Company Data',
|
||||
@@ -341,6 +390,25 @@ export default {
|
||||
submitError: 'Company creation failed. Please try again.',
|
||||
// Success
|
||||
successMessage: 'Company created successfully! Redirecting to garage...',
|
||||
// Smart Company Claiming / Joining
|
||||
activeExistsPanel: 'This company is already registered in the system.',
|
||||
joinRequestButton: 'Send Join Request',
|
||||
joinRequestSent: 'Your join request has been sent to the company administrator!',
|
||||
orphanedPanel: 'This company was previously registered but is currently inactive. You can claim it.',
|
||||
claimRequestButton: 'Request Company Claim',
|
||||
claimCodeSent: 'The claim code has been sent to the company\'s official email!',
|
||||
claimVerifyTitle: 'Verify Claim',
|
||||
claimOtpLabel: 'Claim Code (6 digits)',
|
||||
claimVerifyButton: 'Confirm Claim',
|
||||
claimSuccess: 'Company successfully assigned to your account!',
|
||||
claimError: 'Claim failed. Please try again.',
|
||||
manualFallbackTitle: '📄 Manual Claim (Upload Documents)',
|
||||
manualFallbackDescription: 'If none of the above options work, upload documents (e.g., court ruling, signature specimen) for manual admin approval.',
|
||||
uploadDocumentLabel: 'Upload Document',
|
||||
uploadDocumentButton: 'Choose File',
|
||||
manualSubmitButton: 'Submit Claim',
|
||||
manualSubmitSuccess: 'Your request has been submitted for admin approval!',
|
||||
domainMismatch: 'Your email domain does not match the company\'s domain. Please use the manual claim option.',
|
||||
},
|
||||
organization: {
|
||||
backToPrivate: '⬅️ Back to Private Garage',
|
||||
@@ -353,4 +421,139 @@ export default {
|
||||
hrsz: 'Parcel No.: {value}',
|
||||
placeholder: '—',
|
||||
},
|
||||
admin: {
|
||||
services: {
|
||||
title: 'Services',
|
||||
subtitle: 'Manage service catalog entries, credit costs, and availability',
|
||||
create_button: 'Create New Service',
|
||||
refresh: 'Refresh',
|
||||
loading: 'Loading services...',
|
||||
empty: 'No services found',
|
||||
empty_hint: 'Create your first service to get started.',
|
||||
badge_inactive: 'Inactive',
|
||||
credit_cost_label: 'Credit Cost',
|
||||
credit_unit: 'credits',
|
||||
edit_button: 'Edit',
|
||||
deactivate_button: 'Deactivate',
|
||||
activate_button: 'Activate',
|
||||
// Modal
|
||||
modal_edit_title: 'Edit Service',
|
||||
modal_create_title: 'Create New Service',
|
||||
field_code: 'Service Code',
|
||||
field_code_placeholder: 'e.g. SRV_DATA_EXPORT',
|
||||
field_name: 'Service Name',
|
||||
field_name_placeholder: 'e.g. Data Export',
|
||||
field_description: 'Description',
|
||||
field_description_placeholder: 'Describe what this service does...',
|
||||
field_credit_cost: 'Credit Cost',
|
||||
cancel: 'Cancel',
|
||||
saving: 'Saving...',
|
||||
save_changes: 'Save Changes',
|
||||
create: 'Create Service',
|
||||
},
|
||||
packages: {
|
||||
title: 'Subscription Packages',
|
||||
subtitle: 'Manage subscription tiers, pricing, and entitlements',
|
||||
create_button: 'Create New Package',
|
||||
show_hidden: 'Show inactive packages',
|
||||
refresh: 'Refresh',
|
||||
loading: 'Loading packages...',
|
||||
empty: 'No packages found',
|
||||
empty_hint: 'Create your first subscription package to get started.',
|
||||
badge_inactive: 'Inactive',
|
||||
badge_custom: 'Custom',
|
||||
type_private: 'Private',
|
||||
type_corporate: 'Corporate',
|
||||
monthly: 'Monthly',
|
||||
yearly: 'Yearly',
|
||||
max_vehicles_label: 'Max Vehicles',
|
||||
max_garages_label: 'Max Garages',
|
||||
free_credits_label: 'Free Credits / mo',
|
||||
edit_button: 'Edit',
|
||||
delete_button: 'Deactivate',
|
||||
// Modal
|
||||
modal_edit_title: 'Edit Package',
|
||||
modal_create_title: 'Create New Package',
|
||||
field_name: 'Package Name',
|
||||
field_name_placeholder: 'e.g. private_pro_v2',
|
||||
field_type: 'Package Type',
|
||||
field_monthly_price: 'Monthly Price',
|
||||
field_yearly_price: 'Yearly Price',
|
||||
field_currency: 'Currency',
|
||||
field_max_vehicles: 'Max Vehicles',
|
||||
field_max_garages: 'Max Garages',
|
||||
field_free_credits: 'Free Credits / Month',
|
||||
field_commission_rate: 'Commission Rate (%)',
|
||||
field_referral_bonus: 'Referral Bonus (credits)',
|
||||
field_credit_price: 'Price (Credits)',
|
||||
field_is_public: 'Active / Inactive',
|
||||
field_available_until: 'Expiry Date',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
date_from_label: 'Date From',
|
||||
date_until_label: 'Date Until',
|
||||
field_entitlements: 'Services (Entitlements)',
|
||||
entitlement_placeholder: 'Select a service to add...',
|
||||
cancel: 'Cancel',
|
||||
saving: 'Saving...',
|
||||
save_changes: 'Save Changes',
|
||||
create: 'Create Package',
|
||||
},
|
||||
users: {
|
||||
// Search & Filters
|
||||
search_category_all: 'All',
|
||||
search_category_email: 'E-mail',
|
||||
search_category_name: 'Name',
|
||||
search_category_phone: 'Phone',
|
||||
search_category_id: 'ID',
|
||||
search_category_address: 'Address',
|
||||
search_placeholder: 'Search...',
|
||||
search_button: 'Search',
|
||||
clear_search: 'Clear search',
|
||||
filter_all_roles: 'All Roles',
|
||||
filter_all_status: 'All Status',
|
||||
status_active: 'Active',
|
||||
status_inactive: 'Inactive',
|
||||
status_deleted: 'Deleted',
|
||||
refresh: 'Refresh',
|
||||
// Bulk actions
|
||||
n_selected: '{count} user(s) selected',
|
||||
bulk_ban: 'Ban',
|
||||
bulk_unban: 'Unban',
|
||||
bulk_soft_delete: 'Soft Delete',
|
||||
bulk_restore: 'Restore',
|
||||
bulk_hard_delete: 'Hard Delete',
|
||||
clear_selection: 'Clear Selection',
|
||||
loading: 'Loading...',
|
||||
// Table columns
|
||||
col_email: 'E-mail',
|
||||
col_name: 'Name',
|
||||
col_phone: 'Phone',
|
||||
col_address: 'Address',
|
||||
col_role: 'Role',
|
||||
col_status: 'Status',
|
||||
col_subscription: 'Subscription',
|
||||
col_region: 'Region',
|
||||
col_created: 'Created',
|
||||
col_actions: 'Actions',
|
||||
// Individual actions (kebab menu)
|
||||
actions: 'Actions',
|
||||
action_edit: 'Edit',
|
||||
action_password_reset: 'Password Reset E-mail',
|
||||
action_ban: 'Ban',
|
||||
action_unban: 'Unban',
|
||||
action_soft_delete: 'Soft Delete',
|
||||
action_restore: 'Restore',
|
||||
action_hard_delete: 'Hard Delete',
|
||||
// Empty states
|
||||
empty_search: 'No users match your search',
|
||||
empty_search_hint: 'Try a different search category or term.',
|
||||
empty_no_users: 'No users in the database yet.',
|
||||
// Pagination
|
||||
showing_of: 'Showing {showing} of {total} users',
|
||||
pagination_prev: 'Previous',
|
||||
pagination_next: 'Next',
|
||||
pagination_page: 'Page {current} of {total}',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ export default {
|
||||
close: 'Bezárás',
|
||||
noCompanies: 'Még nincs céged',
|
||||
newCompany: '➕ Új cég',
|
||||
joinCompany: '🔗 Csatlakozás céghez',
|
||||
createCompany: '➕ Cég létrehozása',
|
||||
actions: 'Műveletek',
|
||||
garageSelector: 'Garázs választó',
|
||||
myCompany: '🏢 Cégem',
|
||||
myCompanies: '🏢 Cégeim',
|
||||
personalGarage: '👤 Személyes Garázs',
|
||||
@@ -50,6 +54,8 @@ export default {
|
||||
regionSettings: 'Területi beállítások',
|
||||
regionDescription: 'Dátum, idő és pénznem formátum',
|
||||
dashboard: 'Irányítópult',
|
||||
menu: 'Menü',
|
||||
adminCenter: '🔐 Adminisztrációs Központ',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Betöltés...',
|
||||
@@ -126,6 +132,20 @@ export default {
|
||||
registerFailed: 'Regisztráció sikertelen.',
|
||||
resendFailed: 'Az újraküldés sikertelen. Kérlek próbáld újra később.',
|
||||
forgotFailed: 'Hiba történt a kérelem elküldésekor. Kérlek próbáld újra.',
|
||||
// Account Restore (30-day)
|
||||
restoreLink: 'Törölt fiók visszaállítása',
|
||||
restoreTitle: 'Törölt fiók visszaállítása',
|
||||
restoreStep1: 'Add meg az e-mail címed, és kérj egy visszaállítási kódot.',
|
||||
restoreStep2: 'Add meg a kapott kódot és az új jelszavadat.',
|
||||
restoreEmailLabel: 'E-mail cím',
|
||||
restoreSendCode: 'Visszaállítási kód küldése',
|
||||
restoreOtpLabel: 'Visszaállítási kód (6 számjegy)',
|
||||
restoreNewPassword: 'Új jelszó',
|
||||
restoreVerifyButton: 'Fiók visszaállítása',
|
||||
restoreSuccess: 'A fiókod sikeresen visszaállításra került! Automatikus bejelentkezés...',
|
||||
restoreError: 'A visszaállítás sikertelen. Kérlek próbáld újra.',
|
||||
restoreNotFound: 'Nem található törölt fiók ezzel az e-mail címmel.',
|
||||
restoreCodeSent: 'A visszaállítási kódot elküldtük az e-mail címedre!',
|
||||
},
|
||||
profile: {
|
||||
title: 'Profil beállítások',
|
||||
@@ -205,6 +225,18 @@ export default {
|
||||
currencyHUF: 'HUF (Ft)',
|
||||
currencyEUR: 'EUR (€)',
|
||||
currencyUSD: 'USD ($)',
|
||||
// Delete Account
|
||||
deleteAccount: 'Fiók törlése',
|
||||
deleteAccountConfirm: 'Biztosan törölni szeretnéd a fiókodat?',
|
||||
deleteAccountWarning: 'Biztosan törölni szeretnéd a fiókodat? Ez a művelet végleges és nem vonható vissza.',
|
||||
deleteAccountReason: 'Miért szeretnéd törölni a fiókodat? (opcionális)',
|
||||
deleteAccountButton: 'Igen, töröld a fiókomat',
|
||||
deleteAccountCancel: 'Mégsem',
|
||||
deleteSuccess: 'A fiókod sikeresen törlésre került. Átirányítás...',
|
||||
deleteError: 'Hiba történt a fiók törlése során.',
|
||||
// Last Admin Warning
|
||||
lastAdminWarningTitle: '⚠️ Utolsó adminisztrátor figyelmeztetés',
|
||||
lastAdminWarning: 'Figyelem! Te vagy a céged utolsó aktív adminisztrátora. A fiókod törlésével a cég adatai és flottája passzív (árva) állapotba kerül!',
|
||||
// Back
|
||||
backToGarage: 'Vissza a Garázsba',
|
||||
// Diagnostic
|
||||
@@ -283,6 +315,23 @@ export default {
|
||||
quickActionsTitle: 'Gyorsműveletek',
|
||||
quickNewWaybill: 'Új menetlevél',
|
||||
quickReportIssue: 'Hiba bejelentése',
|
||||
// Organization Settings
|
||||
companyDataTitle: 'Cég adatai',
|
||||
companyDataMenu: 'Cég adatai',
|
||||
close: 'Bezárás',
|
||||
settingsTitle: 'Cég beállítások',
|
||||
settingsDescription: 'Cégadatok szerkesztése',
|
||||
settingsModalTitle: 'Cégadatok szerkesztése',
|
||||
settingsName: 'Cégnév (rövid)',
|
||||
settingsFullName: 'Teljes cégnév',
|
||||
settingsTaxNumber: 'Adószám',
|
||||
settingsDisplayName: 'Megjelenítendő név',
|
||||
settingsAddressZip: 'Irányítószám',
|
||||
settingsAddressCity: 'Város',
|
||||
settingsAddressStreet: 'Utca',
|
||||
settingsAddressHouseNumber: 'Házszám',
|
||||
settingsSaveSuccess: 'Cégadatok sikeresen frissítve!',
|
||||
settingsSaveError: 'Hiba a cégadatok mentése során.',
|
||||
// CompanyOnboardingView
|
||||
onboardingTitle: 'Cég Regisztráció — 3 egyszerű lépésben',
|
||||
stepLegal: 'Cégadatok',
|
||||
@@ -341,6 +390,25 @@ export default {
|
||||
submitError: 'A cég létrehozása sikertelen. Kérlek próbáld újra.',
|
||||
// Success
|
||||
successMessage: 'A cég sikeresen létrejött! Átirányítás a garázsba...',
|
||||
// Smart Company Claiming / Joining
|
||||
activeExistsPanel: 'Ez a cég már regisztrálva van a rendszerben.',
|
||||
joinRequestButton: 'Csatlakozási kérelem küldése',
|
||||
joinRequestSent: 'A csatlakozási kérelmet elküldtük a cég adminisztrátorának!',
|
||||
orphanedPanel: 'Ez a cég korábban regisztrálva volt, de jelenleg inaktív. Igényelheted a céget.',
|
||||
claimRequestButton: 'Cég igénylésének kérése',
|
||||
claimCodeSent: 'Az igénylési kódot elküldtük a cég hivatalos e-mail címére!',
|
||||
claimVerifyTitle: 'Igénylés ellenőrzése',
|
||||
claimOtpLabel: 'Igénylési kód (6 számjegy)',
|
||||
claimVerifyButton: 'Igénylés megerősítése',
|
||||
claimSuccess: 'A cég sikeresen hozzárendelve a fiókodhoz!',
|
||||
claimError: 'Az igénylés sikertelen. Kérlek próbáld újra.',
|
||||
manualFallbackTitle: '📄 Kézi igénylés (dokumentumok feltöltése)',
|
||||
manualFallbackDescription: 'Ha a fenti lehetőségek egyike sem működik, tölts fel dokumentumokat (pl. bírósági végzés, aláírási címpéldány) a manuális adminisztrátori jóváhagyáshoz.',
|
||||
uploadDocumentLabel: 'Dokumentum feltöltése',
|
||||
uploadDocumentButton: 'Fájl kiválasztása',
|
||||
manualSubmitButton: 'Igénylés elküldése',
|
||||
manualSubmitSuccess: 'A kérelmet elküldtük adminisztrátori jóváhagyásra!',
|
||||
domainMismatch: 'Az e-mail címed domainje nem egyezik a cég domainjével. Kérlek használd a kézi igénylést.',
|
||||
},
|
||||
organization: {
|
||||
backToPrivate: '⬅️ Vissza a Privát Garázsba',
|
||||
@@ -353,4 +421,139 @@ export default {
|
||||
hrsz: 'Hrsz.: {value}',
|
||||
placeholder: '—',
|
||||
},
|
||||
admin: {
|
||||
services: {
|
||||
title: 'Szolgáltatások',
|
||||
subtitle: 'Szolgáltatás katalógus bejegyzések, kredit árak és elérhetőség kezelése',
|
||||
create_button: 'Új Szolgáltatás',
|
||||
refresh: 'Frissítés',
|
||||
loading: 'Szolgáltatások betöltése...',
|
||||
empty: 'Nincsenek szolgáltatások',
|
||||
empty_hint: 'Hozd létre az első szolgáltatást a kezdéshez.',
|
||||
badge_inactive: 'Inaktív',
|
||||
credit_cost_label: 'Kredit Ár',
|
||||
credit_unit: 'kredit',
|
||||
edit_button: 'Szerkesztés',
|
||||
deactivate_button: 'Inaktívvá tétel',
|
||||
activate_button: 'Aktiválás',
|
||||
// Modal
|
||||
modal_edit_title: 'Szolgáltatás Szerkesztése',
|
||||
modal_create_title: 'Új Szolgáltatás Létrehozása',
|
||||
field_code: 'Szolgáltatás Kód',
|
||||
field_code_placeholder: 'pl. SRV_DATA_EXPORT',
|
||||
field_name: 'Szolgáltatás Neve',
|
||||
field_name_placeholder: 'pl. Adat Export',
|
||||
field_description: 'Leírás',
|
||||
field_description_placeholder: 'Írd le, mit nyújt ez a szolgáltatás...',
|
||||
field_credit_cost: 'Kredit Ár',
|
||||
cancel: 'Mégsem',
|
||||
saving: 'Mentés...',
|
||||
save_changes: 'Változások Mentése',
|
||||
create: 'Szolgáltatás Létrehozása',
|
||||
},
|
||||
packages: {
|
||||
title: 'Előfizetési Csomagok',
|
||||
subtitle: 'Előfizetési csomagok, árak és jogosultságok kezelése',
|
||||
create_button: 'Új Csomag Létrehozása',
|
||||
show_hidden: 'Inaktív csomagok mutatása',
|
||||
refresh: 'Frissítés',
|
||||
loading: 'Csomagok betöltése...',
|
||||
empty: 'Nincsenek csomagok',
|
||||
empty_hint: 'Hozd létre az első előfizetési csomagot a kezdéshez.',
|
||||
badge_inactive: 'Inaktív',
|
||||
badge_custom: 'Egyedi',
|
||||
type_private: 'Privát',
|
||||
type_corporate: 'Vállalati',
|
||||
monthly: 'Havi',
|
||||
yearly: 'Éves',
|
||||
max_vehicles_label: 'Max. Jármű',
|
||||
max_garages_label: 'Max. Garázs',
|
||||
free_credits_label: 'Ingyenes kredit / hó',
|
||||
edit_button: 'Szerkesztés',
|
||||
delete_button: 'Kivezetés',
|
||||
// Modal
|
||||
modal_edit_title: 'Csomag Szerkesztése',
|
||||
modal_create_title: 'Új Csomag Létrehozása',
|
||||
field_name: 'Csomag Neve',
|
||||
field_name_placeholder: 'pl. private_pro_v2',
|
||||
field_type: 'Csomag Típusa',
|
||||
field_monthly_price: 'Havi Díj',
|
||||
field_yearly_price: 'Éves Díj',
|
||||
field_currency: 'Pénznem',
|
||||
field_max_vehicles: 'Max. Járművek',
|
||||
field_max_garages: 'Max. Garázsok',
|
||||
field_free_credits: 'Ingyenes Kredit / Hó',
|
||||
field_commission_rate: 'Jutalék (%)',
|
||||
field_referral_bonus: 'Ajánlási Bónusz (kredit)',
|
||||
field_credit_price: 'Ár (Kredit)',
|
||||
field_is_public: 'Aktív / Inaktív',
|
||||
field_available_until: 'Lejárat Dátuma',
|
||||
active: 'Aktív',
|
||||
inactive: 'Inaktív',
|
||||
date_from_label: 'Dátum tól',
|
||||
date_until_label: 'Dátum ig',
|
||||
field_entitlements: 'Szolgáltatások (Jogosultságok)',
|
||||
entitlement_placeholder: 'Válassz egy szolgáltatást...',
|
||||
cancel: 'Mégsem',
|
||||
saving: 'Mentés...',
|
||||
save_changes: 'Változások Mentése',
|
||||
create: 'Csomag Létrehozása',
|
||||
},
|
||||
users: {
|
||||
// Search & Filters
|
||||
search_category_all: 'Mind',
|
||||
search_category_email: 'E-mail',
|
||||
search_category_name: 'Név',
|
||||
search_category_phone: 'Telefon',
|
||||
search_category_id: 'Azonosító',
|
||||
search_category_address: 'Cím',
|
||||
search_placeholder: 'Keresés...',
|
||||
search_button: 'Keresés',
|
||||
clear_search: 'Keresés törlése',
|
||||
filter_all_roles: 'Összes szerepkör',
|
||||
filter_all_status: 'Összes állapot',
|
||||
status_active: 'Aktív',
|
||||
status_inactive: 'Inaktív',
|
||||
status_deleted: 'Törölt',
|
||||
refresh: 'Frissítés',
|
||||
// Bulk actions
|
||||
n_selected: '{count} felhasználó kiválasztva',
|
||||
bulk_ban: 'Tiltás',
|
||||
bulk_unban: 'Tiltás feloldása',
|
||||
bulk_soft_delete: 'Lágy törlés',
|
||||
bulk_restore: 'Visszaállítás',
|
||||
bulk_hard_delete: 'Végleges törlés',
|
||||
clear_selection: 'Kijelölés törlése',
|
||||
loading: 'Betöltés...',
|
||||
// Table columns
|
||||
col_email: 'E-mail',
|
||||
col_name: 'Név',
|
||||
col_phone: 'Telefon',
|
||||
col_address: 'Cím',
|
||||
col_role: 'Szerepkör',
|
||||
col_status: 'Állapot',
|
||||
col_subscription: 'Előfizetés',
|
||||
col_region: 'Régió',
|
||||
col_created: 'Létrehozva',
|
||||
col_actions: 'Műveletek',
|
||||
// Individual actions (kebab menu)
|
||||
actions: 'Műveletek',
|
||||
action_edit: 'Szerkesztés',
|
||||
action_password_reset: 'Jelszó visszaállító e-mail',
|
||||
action_ban: 'Tiltás',
|
||||
action_unban: 'Tiltás feloldása',
|
||||
action_soft_delete: 'Lágy törlés',
|
||||
action_restore: 'Visszaállítás',
|
||||
action_hard_delete: 'Végleges törlés',
|
||||
// Empty states
|
||||
empty_search: 'Nincs a keresésnek megfelelő felhasználó',
|
||||
empty_search_hint: 'Próbáld meg más keresési kategóriával vagy kifejezéssel.',
|
||||
empty_no_users: 'Még nincsenek felhasználók az adatbázisban.',
|
||||
// Pagination
|
||||
showing_of: '{showing} / {total} felhasználó',
|
||||
pagination_prev: 'Előző',
|
||||
pagination_next: 'Következő',
|
||||
pagination_page: '{current}. oldal / {total}',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -27,15 +27,34 @@
|
||||
<span class="font-medium">Dashboard</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/users"
|
||||
<router-link
|
||||
to="/admin/users"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path.startsWith('/admin/users') }"
|
||||
>
|
||||
<span class="text-xl">👥</span>
|
||||
<span class="font-medium">User Management</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
|
||||
<router-link
|
||||
to="/admin/packages"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path.startsWith('/admin/packages') }"
|
||||
>
|
||||
<span class="text-xl">📦</span>
|
||||
<span class="font-medium">Subscription Packages</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/services"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path.startsWith('/admin/services') }"
|
||||
>
|
||||
<span class="text-xl">🛠️</span>
|
||||
<span class="font-medium">Services</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/vehicles"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
@@ -59,24 +78,23 @@
|
||||
<span class="font-medium">System Settings</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/audit"
|
||||
<router-link
|
||||
to="/admin/audit"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span class="text-xl">📋</span>
|
||||
<span class="font-medium">Audit Logs</span>
|
||||
</router-link>
|
||||
|
||||
|
||||
<div class="pt-6 border-t border-gray-700">
|
||||
<div class="px-4 py-2 text-xs text-gray-500 uppercase tracking-wider">Tools</div>
|
||||
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors">
|
||||
<span class="text-xl">🔍</span>
|
||||
<span class="font-medium">Database Explorer</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors">
|
||||
<span class="text-xl">📈</span>
|
||||
<span class="font-medium">Analytics</span>
|
||||
</a>
|
||||
<div class="px-4 py-2 text-xs text-gray-500 uppercase tracking-wider">Navigation</div>
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span class="text-xl">🏠</span>
|
||||
<span class="font-medium">Back to Dashboard</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -84,15 +102,12 @@
|
||||
<div class="p-4 border-t border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-600 to-gray-800 flex items-center justify-center">
|
||||
<span class="text-lg">AD</span>
|
||||
<span class="text-lg">{{ userInitials }}</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">Admin User</div>
|
||||
<div class="text-xs text-gray-400">Super Administrator</div>
|
||||
<div class="font-medium truncate">{{ userName }}</div>
|
||||
<div class="text-xs text-gray-400 capitalize">{{ userRoleLabel }}</div>
|
||||
</div>
|
||||
<button class="p-2 hover:bg-gray-700 rounded-lg transition-colors">
|
||||
<span class="text-xl">⚙️</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,9 +131,8 @@
|
||||
<ModeSwitcher />
|
||||
</slot>
|
||||
|
||||
<button class="px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg hover:opacity-90 transition-opacity">
|
||||
Quick Actions
|
||||
</button>
|
||||
<!-- HeaderProfile komponens: profil, admin center, kilépés -->
|
||||
<HeaderProfile />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -126,7 +140,7 @@
|
||||
<!-- Content Area -->
|
||||
<main class="p-8">
|
||||
<div class="bg-gray-800/50 rounded-xl border border-gray-700 p-6">
|
||||
<slot />
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<!-- Footer Stats -->
|
||||
@@ -156,10 +170,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
import ModeSwitcher from '../components/ModeSwitcher.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const currentTime = ref('')
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
@@ -167,9 +184,29 @@ const pageTitle = computed(() => {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1)
|
||||
})
|
||||
|
||||
const userName = computed(() => {
|
||||
if (!authStore.user) return 'Admin User'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`
|
||||
return authStore.user.email || 'Admin User'
|
||||
})
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!authStore.user) return 'AD'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) {
|
||||
return (first_name[0] + last_name[0]).toUpperCase()
|
||||
}
|
||||
return authStore.user.email?.[0]?.toUpperCase() || 'A'
|
||||
})
|
||||
|
||||
const userRoleLabel = computed(() => {
|
||||
return authStore.user?.role?.replace(/_/g, ' ') || 'Administrator'
|
||||
})
|
||||
|
||||
function updateTime() {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('en-US', {
|
||||
currentTime.value = now.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
|
||||
@@ -11,6 +11,47 @@
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<!-- Hamburger Menu (Company Settings) -->
|
||||
<div class="relative hamburger-container">
|
||||
<button
|
||||
@click.stop="isHamburgerOpen = !isHamburgerOpen"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-all duration-200 cursor-pointer"
|
||||
:aria-label="t('company.companyDataMenu')"
|
||||
:title="t('company.companyDataMenu')"
|
||||
>
|
||||
<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="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Hamburger Dropdown -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isHamburgerOpen"
|
||||
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="openCompanyData"
|
||||
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-white/40" 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>
|
||||
{{ t('company.companyDataMenu') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<HeaderCompanySwitcher />
|
||||
<HeaderProfile />
|
||||
</template>
|
||||
@@ -20,11 +61,18 @@
|
||||
<main class="relative z-10">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Company Data Modal -->
|
||||
<CompanyDataModal
|
||||
v-if="showCompanyDataModal"
|
||||
@close="showCompanyDataModal = false"
|
||||
@saved="showCompanyDataModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -33,12 +81,17 @@ import BaseHeader from '../components/layout/BaseHeader.vue'
|
||||
import HeaderLogo from '../components/header/HeaderLogo.vue'
|
||||
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
import CompanyDataModal from '../components/organization/CompanyDataModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// ── Hamburger Menu State ──────────────────────────────────────────
|
||||
const isHamburgerOpen = ref(false)
|
||||
const showCompanyDataModal = ref(false)
|
||||
|
||||
// ── Resolve organization display name from route params ────────────
|
||||
const orgDisplayName = computed(() => {
|
||||
const id = Number(route.params.id)
|
||||
@@ -54,6 +107,20 @@ const currentOrganization = computed(() => {
|
||||
return authStore.myOrganizations.find((o) => o.organization_id === id) || null
|
||||
})
|
||||
|
||||
// ── Hamburger Menu Handlers ───────────────────────────────────────
|
||||
function openCompanyData() {
|
||||
isHamburgerOpen.value = false
|
||||
showCompanyDataModal.value = true
|
||||
}
|
||||
|
||||
// ── Close hamburger on outside click ──────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isHamburgerOpen.value && !target.closest('.hamburger-container')) {
|
||||
isHamburgerOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply theme when organization loads or changes ────────────────
|
||||
function applyOrgTheme() {
|
||||
const org = currentOrganization.value
|
||||
@@ -73,10 +140,12 @@ watch(
|
||||
// Apply theme on mount
|
||||
onMounted(() => {
|
||||
applyOrgTheme()
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
})
|
||||
|
||||
// Reset theme when leaving the organization view
|
||||
onUnmounted(() => {
|
||||
themeStore.resetTheme()
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,71 @@
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<!-- Hamburger Menu (Private Garage Navigation) -->
|
||||
<div class="relative hamburger-container">
|
||||
<button
|
||||
@click.stop="isHamburgerOpen = !isHamburgerOpen"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-all duration-200 cursor-pointer"
|
||||
:aria-label="t('header.menu')"
|
||||
:title="t('header.menu')"
|
||||
>
|
||||
<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="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Hamburger Dropdown -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isHamburgerOpen"
|
||||
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<div class="py-1">
|
||||
<!-- Járművek -->
|
||||
<button
|
||||
@click="navigateToCard('vehicles')"
|
||||
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-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{{ t('menu.garage') }}
|
||||
</button>
|
||||
|
||||
<!-- Költségek -->
|
||||
<button
|
||||
@click="navigateToCard('costs')"
|
||||
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-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ t('menu.finance') }}
|
||||
</button>
|
||||
|
||||
<!-- Szerviz kereső -->
|
||||
<button
|
||||
@click="navigateToCard('service')"
|
||||
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-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{{ t('menu.features') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<HeaderCompanySwitcher />
|
||||
<HeaderProfile />
|
||||
</template>
|
||||
@@ -27,7 +92,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import BaseHeader from '../components/layout/BaseHeader.vue'
|
||||
@@ -35,9 +101,14 @@ import HeaderLogo from '../components/header/HeaderLogo.vue'
|
||||
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Hamburger Menu State ──────────────────────────────────────────
|
||||
const isHamburgerOpen = ref(false)
|
||||
|
||||
// ── Center label: shows "{firstName}_garage" ───────────────────────
|
||||
const centerLabel = computed(() => {
|
||||
const firstName = authStore.user?.first_name ||
|
||||
@@ -45,4 +116,27 @@ const centerLabel = computed(() => {
|
||||
t('header.user')
|
||||
return t('header.personalGarageLabel', { name: firstName })
|
||||
})
|
||||
|
||||
// ── Navigate to dashboard with card query param ────────────────────
|
||||
function navigateToCard(cardId: string) {
|
||||
isHamburgerOpen.value = false
|
||||
// 'vehicles' opens the flip modal with PrivateVehicleManager
|
||||
// 'costs' and 'service' navigate to dashboard where their cards are already visible inline
|
||||
if (cardId === 'costs' || cardId === 'service') {
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.push({ path: '/dashboard', query: { card: cardId } })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close hamburger on outside click ──────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isHamburgerOpen.value && !target.closest('.hamburger-container')) {
|
||||
isHamburgerOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
|
||||
@@ -67,6 +67,33 @@ const router = createRouter({
|
||||
// The auth store will be checked at runtime
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('../layouts/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('../views/admin/AdminDashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'admin-users',
|
||||
component: () => import('../views/admin/AdminUsersView.vue')
|
||||
},
|
||||
{
|
||||
path: 'packages',
|
||||
name: 'admin-packages',
|
||||
component: () => import('../views/admin/AdminPackagesView.vue')
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
name: 'admin-services',
|
||||
component: () => import('../views/admin/AdminServicesView.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -96,6 +123,17 @@ router.beforeEach(async (to, _from, next) => {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
// ── Admin RBAC Guard ──────────────────────────────────────────────
|
||||
// If the route requires admin privileges, check the user's role.
|
||||
if (to.meta.requiresAdmin) {
|
||||
const role = authStore.user?.role
|
||||
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
|
||||
if (!role || !adminRoles.includes(role)) {
|
||||
// Non-admin user trying to access admin area → redirect to dashboard
|
||||
return next('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
244
frontend/src/stores/adminPackages.ts
Normal file
244
frontend/src/stores/adminPackages.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 📦 Admin Packages Store
|
||||
*
|
||||
* Pinia store for managing SubscriptionTier packages.
|
||||
* Mirrors the backend admin_packages.py API endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /admin/packages/ — List all packages
|
||||
* POST /admin/packages/ — Create a new package
|
||||
* PATCH /admin/packages/{id} — Update a package
|
||||
* DELETE /admin/packages/{id} — Soft-delete (deactivate) a package
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
// ── TypeScript Interfaces (mirroring backend Pydantic schemas) ───────────────
|
||||
|
||||
export interface PricingModel {
|
||||
monthly_price: number
|
||||
yearly_price: number
|
||||
currency: string
|
||||
credit_price?: number | null
|
||||
}
|
||||
|
||||
export interface AllowancesModel {
|
||||
max_vehicles: number
|
||||
max_garages: number
|
||||
monthly_free_credits: number
|
||||
}
|
||||
|
||||
export interface LifecycleModel {
|
||||
is_public: boolean
|
||||
available_until?: string | null
|
||||
}
|
||||
|
||||
export interface AffiliateModel {
|
||||
commission_rate_percent: number
|
||||
referral_bonus_credits: number
|
||||
}
|
||||
|
||||
export interface SubscriptionRulesModel {
|
||||
type: 'private' | 'corporate'
|
||||
display_name?: string | null
|
||||
pricing: PricingModel
|
||||
allowances: AllowancesModel
|
||||
entitlements: string[]
|
||||
affiliate: AffiliateModel
|
||||
lifecycle?: LifecycleModel | null
|
||||
}
|
||||
|
||||
export interface SubscriptionTierItem {
|
||||
id: number
|
||||
name: string
|
||||
rules: SubscriptionRulesModel
|
||||
is_custom: boolean
|
||||
}
|
||||
|
||||
export interface SubscriptionTierListResponse {
|
||||
total: number
|
||||
tiers: SubscriptionTierItem[]
|
||||
}
|
||||
|
||||
export interface SubscriptionTierCreatePayload {
|
||||
name: string
|
||||
rules: SubscriptionRulesModel
|
||||
is_custom?: boolean
|
||||
}
|
||||
|
||||
export interface SubscriptionTierUpdatePayload {
|
||||
name?: string
|
||||
rules?: SubscriptionRulesModel
|
||||
is_custom?: boolean
|
||||
}
|
||||
|
||||
export interface DeletePackageResponse {
|
||||
status: string
|
||||
message: string
|
||||
tier_id: number
|
||||
is_public: boolean
|
||||
}
|
||||
|
||||
// ── Available Entitlements (service codes) ───────────────────────────────────
|
||||
|
||||
export const AVAILABLE_ENTITLEMENTS: string[] = [
|
||||
'SRV_DATA_EXPORT',
|
||||
'SRV_AI_UPLOAD',
|
||||
'SRV_ACCOUNTING_SYNC',
|
||||
'SRV_API_ACCESS',
|
||||
'SRV_MULTI_USER',
|
||||
'SRV_PRIORITY_SUPPORT',
|
||||
'SRV_ADVANCED_ANALYTICS',
|
||||
'SRV_CUSTOM_REPORTS',
|
||||
'SRV_GARAGE_SHARING',
|
||||
'SRV_SERVICE_HISTORY',
|
||||
'SRV_FUEL_MANAGEMENT',
|
||||
'SRV_MAINTENANCE_ALERTS',
|
||||
'SRV_ODOMETER_TRACKING',
|
||||
'SRV_DOCUMENT_STORAGE',
|
||||
'SRV_INSURANCE_INTEGRATION',
|
||||
]
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const useAdminPackagesStore = defineStore('adminPackages', () => {
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
const tiers = ref<SubscriptionTierItem[]>([])
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all subscription packages from GET /admin/packages/.
|
||||
*/
|
||||
async function fetchPackages(params: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
include_hidden?: boolean
|
||||
type_filter?: string
|
||||
date_from?: string
|
||||
date_until?: string
|
||||
} = {}) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, any> = {
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 100,
|
||||
}
|
||||
if (params.include_hidden) {
|
||||
queryParams.include_hidden = true
|
||||
}
|
||||
if (params.type_filter) {
|
||||
queryParams.type_filter = params.type_filter
|
||||
}
|
||||
if (params.date_from) {
|
||||
queryParams.date_from = params.date_from
|
||||
}
|
||||
if (params.date_until) {
|
||||
queryParams.date_until = params.date_until
|
||||
}
|
||||
|
||||
const res = await api.get('/admin/packages', { params: queryParams })
|
||||
const data = res.data as SubscriptionTierListResponse
|
||||
console.log('[AdminPackages] API response:', data)
|
||||
console.log('[AdminPackages] tiers array:', data.tiers)
|
||||
console.log('[AdminPackages] tiers length:', data.tiers?.length)
|
||||
tiers.value = data.tiers
|
||||
total.value = data.total
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch packages'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subscription package via POST /admin/packages/.
|
||||
* NOTE: Do NOT send the `id` field — PostgreSQL auto-increments it.
|
||||
*/
|
||||
async function createPackage(payload: SubscriptionTierCreatePayload): Promise<SubscriptionTierItem> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/admin/packages', payload)
|
||||
const created = res.data as SubscriptionTierItem
|
||||
// Refresh the list
|
||||
await fetchPackages({ include_hidden: true })
|
||||
return created
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to create package'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing package via PATCH /admin/packages/{id}.
|
||||
*/
|
||||
async function updatePackage(
|
||||
tierId: number,
|
||||
payload: SubscriptionTierUpdatePayload,
|
||||
): Promise<SubscriptionTierItem> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.patch(`/admin/packages/${tierId}`, payload)
|
||||
const updated = res.data as SubscriptionTierItem
|
||||
// Refresh the list
|
||||
await fetchPackages({ include_hidden: true })
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to update package'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete (deactivate) a package via DELETE /admin/packages/{id}.
|
||||
* Sets lifecycle.is_public = false.
|
||||
*/
|
||||
async function deletePackage(tierId: number): Promise<DeletePackageResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.delete(`/admin/packages/${tierId}`)
|
||||
const result = res.data as DeletePackageResponse
|
||||
// Refresh the list
|
||||
await fetchPackages({ include_hidden: true })
|
||||
return result
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to delete package'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tiers,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchPackages,
|
||||
createPackage,
|
||||
updatePackage,
|
||||
deletePackage,
|
||||
}
|
||||
})
|
||||
142
frontend/src/stores/adminServices.ts
Normal file
142
frontend/src/stores/adminServices.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 🛠️ Admin Services Store
|
||||
*
|
||||
* Pinia store for managing ServiceCatalog entries.
|
||||
* Mirrors the backend admin_services.py API endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /admin/services/ — List all services
|
||||
* POST /admin/services/ — Create a new service
|
||||
* PATCH /admin/services/{id} — Update a service
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
// ── TypeScript Interfaces (mirroring backend Pydantic schemas) ───────────────
|
||||
|
||||
export interface ServiceCatalogItem {
|
||||
id: number
|
||||
service_code: string
|
||||
name: string
|
||||
description: string | null
|
||||
credit_cost: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ServiceCatalogCreatePayload {
|
||||
service_code: string
|
||||
name: string
|
||||
description?: string | null
|
||||
credit_cost?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface ServiceCatalogUpdatePayload {
|
||||
name?: string
|
||||
description?: string | null
|
||||
credit_cost?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const useAdminServicesStore = defineStore('adminServices', () => {
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
const services = ref<ServiceCatalogItem[]>([])
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all services from GET /admin/services/.
|
||||
*/
|
||||
async function fetchServices(params: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
is_active?: boolean | null
|
||||
} = {}) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, any> = {
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 200,
|
||||
}
|
||||
if (params.is_active !== undefined && params.is_active !== null) {
|
||||
queryParams.is_active = params.is_active
|
||||
}
|
||||
|
||||
const res = await api.get('/admin/services', { params: queryParams })
|
||||
const data = res.data as ServiceCatalogItem[]
|
||||
services.value = data
|
||||
total.value = data.length
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch services'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new service via POST /admin/services/.
|
||||
*/
|
||||
async function createService(payload: ServiceCatalogCreatePayload): Promise<ServiceCatalogItem> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/admin/services', payload)
|
||||
const created = res.data as ServiceCatalogItem
|
||||
await fetchServices()
|
||||
return created
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to create service'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing service via PATCH /admin/services/{id}.
|
||||
*/
|
||||
async function updateService(
|
||||
serviceId: number,
|
||||
payload: ServiceCatalogUpdatePayload,
|
||||
): Promise<ServiceCatalogItem> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.patch(`/admin/services/${serviceId}`, payload)
|
||||
const updated = res.data as ServiceCatalogItem
|
||||
await fetchServices()
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to update service'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
services,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchServices,
|
||||
createService,
|
||||
updateService,
|
||||
}
|
||||
})
|
||||
129
frontend/src/stores/adminUsers.ts
Normal file
129
frontend/src/stores/adminUsers.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// /opt/docker/dev/service_finder/frontend/src/stores/adminUsers.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
export interface AdminUserItem {
|
||||
id: number
|
||||
email: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
is_deleted: boolean
|
||||
subscription_plan: string
|
||||
region_code: string
|
||||
preferred_language: string
|
||||
created_at: string | null
|
||||
deleted_at: string | null
|
||||
// Person adatok
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
phone: string | null
|
||||
// Címadatok
|
||||
postal_code: string | null
|
||||
city: string | null
|
||||
street: string | null
|
||||
house_number: string | null
|
||||
address: string | null
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
users: AdminUserItem[]
|
||||
}
|
||||
|
||||
export interface BulkActionResponse {
|
||||
status: string
|
||||
action: string
|
||||
affected_count: number
|
||||
total_requested: number
|
||||
missing_ids: number[] | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export const useAdminUsersStore = defineStore('adminUsers', () => {
|
||||
// ────────────────────────────── State ──────────────────────────────
|
||||
const users = ref<AdminUserItem[]>([])
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ────────────────────────────── Actions ────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch paginated and filtered user list from GET /admin/users.
|
||||
* Supports targeted search with search_term + search_category.
|
||||
*/
|
||||
async function fetchUsers(params: {
|
||||
skip?: number
|
||||
limit?: number
|
||||
search_term?: string
|
||||
search_category?: string
|
||||
role?: string
|
||||
is_active?: boolean
|
||||
is_deleted?: boolean
|
||||
} = {}) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, any> = {
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 50,
|
||||
}
|
||||
if (params.search_term) queryParams.search_term = params.search_term
|
||||
if (params.search_category) queryParams.search_category = params.search_category
|
||||
if (params.role) queryParams.role = params.role
|
||||
if (params.is_active !== undefined) queryParams.is_active = params.is_active
|
||||
if (params.is_deleted !== undefined) queryParams.is_deleted = params.is_deleted
|
||||
|
||||
const res = await api.get('/admin/users', { params: queryParams })
|
||||
const data = res.data as UserListResponse
|
||||
users.value = data.users
|
||||
total.value = data.total
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch users'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a bulk action on selected users via POST /admin/users/bulk-action.
|
||||
*/
|
||||
async function bulkAction(userIds: number[], action: string): Promise<BulkActionResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/admin/users/bulk-action', {
|
||||
user_ids: userIds,
|
||||
action,
|
||||
})
|
||||
const data = res.data as BulkActionResponse
|
||||
// Refresh the user list after a successful bulk action
|
||||
await fetchUsers()
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || `Failed to execute bulk action: ${action}`
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
users,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchUsers,
|
||||
bulkAction,
|
||||
}
|
||||
})
|
||||
@@ -474,6 +474,105 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current user's account (soft delete).
|
||||
* DELETE /users/me with optional reason as query parameter.
|
||||
* On success, clears all local state and redirects to landing page.
|
||||
* If the backend returns is_last_admin=true, the response includes
|
||||
* a warning detail that the caller can display.
|
||||
*/
|
||||
async function deleteAccount(reason?: string): Promise<{ is_last_admin?: boolean }> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (reason) {
|
||||
params.reason = reason
|
||||
}
|
||||
const res = await api.delete('/users/me', { params })
|
||||
// Check if backend returned is_last_admin warning
|
||||
const isLastAdmin = res.data?.is_last_admin === true
|
||||
// Clear all local state — same as logout but without API call
|
||||
token.value = null
|
||||
user.value = null
|
||||
myOrganizations.value = []
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
router.push('/')
|
||||
return { is_last_admin: isLastAdmin }
|
||||
} catch (err: any) {
|
||||
// If the error response contains is_last_admin, pass it through
|
||||
if (err.response?.data?.is_last_admin) {
|
||||
error.value = err.response?.data?.detail || 'profile.deleteError'
|
||||
throw { ...err, is_last_admin: true }
|
||||
}
|
||||
error.value = err?.response?.data?.detail || 'profile.deleteError'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request account restoration code (Step 1 of Account Restore).
|
||||
* POST /auth/restore/request with { email }.
|
||||
* Sends a 6-digit OTP to the user's email.
|
||||
*/
|
||||
async function requestRestore(email: string): Promise<{ status: string; message: string }> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/restore/request', { email })
|
||||
return res.data as { status: string; message: string }
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.restoreError')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify account restoration code and set new password (Step 2 of Account Restore).
|
||||
* POST /auth/restore/verify with { email, otp, new_password }.
|
||||
* On success, returns JWT tokens and auto-logs in the user.
|
||||
*/
|
||||
async function verifyRestore(email: string, otp: string, newPassword: string): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/restore/verify', {
|
||||
email,
|
||||
otp,
|
||||
new_password: newPassword,
|
||||
})
|
||||
const data = res.data as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// Persist token
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token)
|
||||
}
|
||||
token.value = data.access_token
|
||||
|
||||
// Fetch user profile and organizations
|
||||
await fetchUser()
|
||||
await fetchMyOrganizations()
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err, 'auth.restoreError')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any stored error message.
|
||||
*/
|
||||
@@ -591,6 +690,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
completeKyc,
|
||||
updatePerson,
|
||||
changePassword,
|
||||
deleteAccount,
|
||||
requestRestore,
|
||||
verifyRestore,
|
||||
logout,
|
||||
init,
|
||||
clearError,
|
||||
|
||||
@@ -9,9 +9,8 @@ export interface AddExpensePayload {
|
||||
asset_id: string
|
||||
organization_id?: number | null // Auto-resolved by backend from asset if omitted
|
||||
category_id: number
|
||||
cost_type: string
|
||||
amount_local: number
|
||||
currency_local?: string
|
||||
amount_net: number
|
||||
currency?: string
|
||||
date: string
|
||||
mileage_at_cost?: number | null
|
||||
description?: string | null
|
||||
@@ -52,9 +51,8 @@ export const useCostStore = defineStore('cost', () => {
|
||||
const body: Record<string, any> = {
|
||||
asset_id: payload.asset_id,
|
||||
category_id: payload.category_id,
|
||||
cost_type: payload.cost_type,
|
||||
amount_local: payload.amount_local,
|
||||
currency_local: payload.currency_local || 'HUF',
|
||||
amount_net: payload.amount_net,
|
||||
currency: payload.currency || 'HUF',
|
||||
date: payload.date,
|
||||
mileage_at_cost: payload.mileage_at_cost ?? null,
|
||||
description: payload.description || null,
|
||||
|
||||
@@ -119,8 +119,17 @@
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex-1 flex flex-col gap-2.5 justify-center">
|
||||
<!-- No vehicle warning -->
|
||||
<div
|
||||
v-if="!selectedActionVehicle"
|
||||
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
<span>⚠️ Nincs kiválasztva jármű. Adj hozzá egy járművet először!</span>
|
||||
</div>
|
||||
|
||||
<!-- ⛽ Tankolás (Big, prominent) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openFuelModal"
|
||||
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
@@ -131,6 +140,7 @@
|
||||
|
||||
<!-- 🅿️ Parkolás / Útdíj (Medium) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openFeeModal"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||
>
|
||||
@@ -141,6 +151,7 @@
|
||||
|
||||
<!-- ➕ További költségek (Medium) -->
|
||||
<button
|
||||
v-if="selectedActionVehicle"
|
||||
@click="openComplexModal"
|
||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||
>
|
||||
@@ -318,8 +329,8 @@
|
||||
<PrivateVehicleManager
|
||||
v-if="activeCard === 'vehicles'"
|
||||
:target-vehicle-id="selectedTargetId"
|
||||
:edit-target-vehicle="editTargetVehicle"
|
||||
@clear-edit-target="editTargetVehicle = null"
|
||||
@add-new="handleAddNew"
|
||||
@edit-vehicle="handleEditVehicle"
|
||||
/>
|
||||
|
||||
<!-- ── Fallback for other cards ── -->
|
||||
@@ -370,23 +381,49 @@
|
||||
@edit="handleEditFromDetail"
|
||||
@set-primary="handleSetPrimaryFromDetail"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Vehicle Form Modal (Add) — standalone, decoupled from Manager
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<VehicleFormModal
|
||||
:is-open="isAddFormOpen"
|
||||
@close="isAddFormOpen = false"
|
||||
@saved="onAddVehicleSaved"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Vehicle Form Modal (Edit) — standalone, decoupled from Manager
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<VehicleFormModal
|
||||
:is-open="isEditFormOpen"
|
||||
:vehicle="editTargetVehicle"
|
||||
@close="closeEditForm"
|
||||
@saved="onEditVehicleSaved"
|
||||
@deleted="onVehicleDeleted"
|
||||
/>
|
||||
</div><!-- end min-h-screen -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useVehicleStore } from '../stores/vehicle'
|
||||
import type { VehicleData } from '../types/vehicle'
|
||||
import api from '../api/axios'
|
||||
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
|
||||
import VehicleCardCompact from '../components/vehicle/VehicleCardCompact.vue'
|
||||
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
|
||||
import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue'
|
||||
import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue'
|
||||
import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
@@ -405,9 +442,6 @@ const openCard = (cardId: string, targetId: string | null = null) => {
|
||||
const isVehicleDetailOpen = ref(false)
|
||||
const detailVehicle = ref<VehicleData | null>(null)
|
||||
|
||||
// ── Task 3: Edit target for direct VehicleFormModal open ──
|
||||
const editTargetVehicle = ref<VehicleData | null>(null)
|
||||
|
||||
function openVehicleDetail(vehicle: VehicleData) {
|
||||
detailVehicle.value = vehicle
|
||||
isVehicleDetailOpen.value = true
|
||||
@@ -418,11 +452,115 @@ function closeVehicleDetail() {
|
||||
detailVehicle.value = null
|
||||
}
|
||||
|
||||
// ── Vehicle Form Modal State (standalone, decoupled from Manager) ──
|
||||
const isAddFormOpen = ref(false)
|
||||
const isEditFormOpen = ref(false)
|
||||
const editTargetVehicle = ref<VehicleData | null>(null)
|
||||
const isCheckingQuota = ref(false)
|
||||
|
||||
/**
|
||||
* Return-State flag: when true, closing the edit form should re-open
|
||||
* the VehicleDetailModal instead of returning to the dashboard.
|
||||
*/
|
||||
const returnToVehicleDetail = ref(false)
|
||||
|
||||
/**
|
||||
* Preemptive quota check before opening the add-vehicle modal.
|
||||
* Calls GET /api/v1/assets/vehicles/quota-status.
|
||||
* If the user has reached their limit, shows a blocking toast/alert instead of opening the form.
|
||||
*/
|
||||
async function handleAddNew() {
|
||||
if (isCheckingQuota.value) return
|
||||
isCheckingQuota.value = true
|
||||
|
||||
try {
|
||||
const res = await api.get('/assets/vehicles/quota-status')
|
||||
const { can_add, current_count, limit } = res.data
|
||||
|
||||
if (!can_add) {
|
||||
alert(
|
||||
`Elérted a csomagodhoz tartozó járműlimitet (${current_count}/${limit})! ` +
|
||||
'Kérjük, válts magasabb csomagra az új jármű rögzítéséhez.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
isAddFormOpen.value = true
|
||||
} catch (err: any) {
|
||||
console.error('[DashboardView] Quota check failed:', err)
|
||||
isAddFormOpen.value = true
|
||||
} finally {
|
||||
isCheckingQuota.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onAddVehicleSaved() {
|
||||
isAddFormOpen.value = false
|
||||
}
|
||||
|
||||
function handleEditVehicle(vehicle: VehicleData) {
|
||||
editTargetVehicle.value = vehicle ? { ...vehicle } : null
|
||||
isEditFormOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditForm() {
|
||||
isEditFormOpen.value = false
|
||||
// If we came from the detail modal, return to it
|
||||
if (returnToVehicleDetail.value) {
|
||||
returnToVehicleDetail.value = false
|
||||
const vehicle = editTargetVehicle.value
|
||||
editTargetVehicle.value = null
|
||||
if (vehicle) {
|
||||
nextTick(() => {
|
||||
detailVehicle.value = vehicle
|
||||
isVehicleDetailOpen.value = true
|
||||
})
|
||||
}
|
||||
} else {
|
||||
editTargetVehicle.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onEditVehicleSaved(savedVehicle?: VehicleData) {
|
||||
isEditFormOpen.value = false
|
||||
// If we came from the detail modal, return to it
|
||||
if (returnToVehicleDetail.value) {
|
||||
returnToVehicleDetail.value = false
|
||||
// Use the saved vehicle (with updated data) if available, otherwise fallback
|
||||
const vehicle = savedVehicle || editTargetVehicle.value
|
||||
editTargetVehicle.value = null
|
||||
if (vehicle) {
|
||||
nextTick(() => {
|
||||
detailVehicle.value = vehicle
|
||||
isVehicleDetailOpen.value = true
|
||||
})
|
||||
}
|
||||
} else {
|
||||
editTargetVehicle.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the edit VehicleFormModal emits 'deleted'.
|
||||
* Completely resets the return-state and closes the edit form,
|
||||
* then refreshes the vehicle list so the deleted vehicle disappears.
|
||||
*/
|
||||
function onVehicleDeleted() {
|
||||
returnToVehicleDetail.value = false
|
||||
isEditFormOpen.value = false
|
||||
editTargetVehicle.value = null
|
||||
vehicleStore.fetchVehicles()
|
||||
}
|
||||
|
||||
// ── Bridge: Detail → Edit (opens edit modal directly, no Manager card) ──
|
||||
function handleEditFromDetail(vehicle: VehicleData) {
|
||||
returnToVehicleDetail.value = true
|
||||
closeVehicleDetail()
|
||||
// Task 3: Open the vehicles card AND pass the vehicle to edit directly
|
||||
editTargetVehicle.value = vehicle
|
||||
activeCard.value = 'vehicles'
|
||||
// Use nextTick so detail modal closes before edit opens
|
||||
nextTick(() => {
|
||||
editTargetVehicle.value = vehicle ? { ...vehicle } : null
|
||||
isEditFormOpen.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function handleSetPrimaryFromDetail(vehicle: VehicleData) {
|
||||
@@ -497,12 +635,28 @@ const badges = ref([
|
||||
onMounted(() => {
|
||||
vehicleStore.fetchVehicles()
|
||||
authStore.fetchMyOrganizations()
|
||||
|
||||
// ── Handle query param card navigation ──────────────────────────
|
||||
const cardParam = route.query.card as string | undefined
|
||||
if (cardParam) {
|
||||
openCard(cardParam)
|
||||
// Clean up the query param so it doesn't re-trigger
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for vehicles to load, then auto-select
|
||||
watch(() => vehicleStore.vehicles.length, () => {
|
||||
autoSelectVehicle()
|
||||
}, { immediate: true })
|
||||
|
||||
// ── Watch for query param changes (hamburger menu navigation) ────
|
||||
watch(() => route.query.card, (newCard) => {
|
||||
if (newCard && typeof newCard === 'string') {
|
||||
openCard(newCard)
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -172,6 +172,17 @@
|
||||
</svg>
|
||||
{{ t('profile.changePassword') }}
|
||||
</button>
|
||||
|
||||
<!-- ── Delete Account Button ── -->
|
||||
<button
|
||||
@click="showDeleteModal = true"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-red-600/20 border border-red-500/40 px-6 py-2.5 text-sm font-semibold text-red-400 transition-all duration-200 hover:bg-red-600/30 cursor-pointer"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ t('profile.deleteAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -792,6 +803,109 @@
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Delete Account Confirmation Modal ──────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showDeleteModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="closeDeleteModal"
|
||||
>
|
||||
<div
|
||||
@click.stop
|
||||
class="relative w-full max-w-md mx-4 rounded-2xl border border-red-500/20 bg-black/80 p-6 backdrop-blur-2xl shadow-2xl"
|
||||
>
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-bold text-white">{{ t('profile.deleteAccountConfirm') }}</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="closeDeleteModal"
|
||||
class="text-white/40 transition-colors duration-200 hover:text-red-400 cursor-pointer"
|
||||
:aria-label="t('header.close')"
|
||||
>
|
||||
<svg 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">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="space-y-4">
|
||||
<!-- Last Admin Warning -->
|
||||
<div
|
||||
v-if="isLastAdmin"
|
||||
class="rounded-xl px-4 py-3 text-sm bg-orange-500/20 text-orange-300 border border-orange-500/30"
|
||||
>
|
||||
<p class="font-semibold mb-1">{{ t('profile.lastAdminWarningTitle') }}</p>
|
||||
<p>{{ t('profile.lastAdminWarning') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Warning message -->
|
||||
<div class="rounded-xl px-4 py-3 text-sm bg-red-500/10 text-red-300 border border-red-500/20">
|
||||
<p class="font-semibold mb-1">{{ t('profile.deleteAccountWarning') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Reason input (optional) -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm text-white/50">{{ t('profile.deleteAccountReason') }}</label>
|
||||
<textarea
|
||||
v-model="deleteReason"
|
||||
rows="2"
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-red-500/50 focus:outline-none resize-none"
|
||||
:placeholder="t('profile.deleteAccountReason')"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="deleteError"
|
||||
class="rounded-xl px-4 py-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20"
|
||||
>
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
|
||||
<!-- Success message -->
|
||||
<div
|
||||
v-if="deleteSuccess"
|
||||
class="rounded-xl px-4 py-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20"
|
||||
>
|
||||
{{ deleteSuccess }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
@click="closeDeleteModal"
|
||||
:disabled="isDeleting"
|
||||
class="rounded-xl border border-white/20 bg-white/5 px-4 py-2 text-sm text-white/70 transition-all duration-200 hover:bg-white/10 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{{ t('profile.deleteAccountCancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="submitDeleteAccount"
|
||||
:disabled="isDeleting"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-red-600 px-6 py-2 text-sm text-white font-semibold transition-all duration-200 hover:bg-red-700 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<svg v-if="isDeleting" class="w-4 h-4 animate-spin" 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>
|
||||
<svg v-else 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ t('profile.deleteAccountButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div><!-- /backdrop overlay -->
|
||||
</div><!-- /root -->
|
||||
</template>
|
||||
@@ -946,6 +1060,8 @@ function handleEscapeKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showPasswordModal.value) {
|
||||
closePasswordModal()
|
||||
} else if (showDeleteModal.value) {
|
||||
closeDeleteModal()
|
||||
} else {
|
||||
closeProfile()
|
||||
}
|
||||
@@ -1232,4 +1348,44 @@ async function submitPasswordChange() {
|
||||
isPasswordSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete Account Modal State ─────────────────────────────────────────
|
||||
const showDeleteModal = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const deleteReason = ref('')
|
||||
const deleteError = ref('')
|
||||
const deleteSuccess = ref('')
|
||||
const isLastAdmin = ref(false)
|
||||
|
||||
function closeDeleteModal() {
|
||||
showDeleteModal.value = false
|
||||
deleteReason.value = ''
|
||||
deleteError.value = ''
|
||||
deleteSuccess.value = ''
|
||||
isLastAdmin.value = false
|
||||
}
|
||||
|
||||
async function submitDeleteAccount() {
|
||||
deleteError.value = ''
|
||||
deleteSuccess.value = ''
|
||||
isDeleting.value = true
|
||||
|
||||
try {
|
||||
const result = await authStore.deleteAccount(deleteReason.value || undefined)
|
||||
if (result?.is_last_admin) {
|
||||
isLastAdmin.value = true
|
||||
}
|
||||
deleteSuccess.value = t('profile.deleteSuccess')
|
||||
setTimeout(() => {
|
||||
closeDeleteModal()
|
||||
}, 2000)
|
||||
} catch (err: any) {
|
||||
if (err.is_last_admin) {
|
||||
isLastAdmin.value = true
|
||||
}
|
||||
deleteError.value = err?.response?.data?.detail || t('profile.deleteError')
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
95
frontend/src/views/admin/AdminDashboardView.vue
Normal file
95
frontend/src/views/admin/AdminDashboardView.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="admin-dashboard">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-white">Admin Dashboard</h2>
|
||||
<p class="text-gray-400 mt-1">System overview and key metrics</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-blue-500/30 transition-colors">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<span class="text-2xl">👥</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">Total</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-white">--</div>
|
||||
<div class="text-sm text-gray-400 mt-1">Registered Users</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-green-500/30 transition-colors">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<span class="text-2xl">🚗</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">Active</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-white">--</div>
|
||||
<div class="text-sm text-gray-400 mt-1">Vehicle Assets</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-purple-500/30 transition-colors">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||
<span class="text-2xl">🏢</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">Total</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-white">--</div>
|
||||
<div class="text-sm text-gray-400 mt-1">Organizations</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-yellow-500/30 transition-colors">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
||||
<span class="text-2xl">🔔</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400">24h</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-white">--</div>
|
||||
<div class="text-sm text-gray-400 mt-1">Critical Alerts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-gray-800/40 rounded-xl border border-gray-700 p-6 mb-8">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Quick Actions</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<button class="flex flex-col items-center gap-2 p-4 rounded-lg bg-gray-700/50 hover:bg-gray-700 transition-colors">
|
||||
<span class="text-2xl">🔍</span>
|
||||
<span class="text-sm text-gray-300">Browse Users</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-2 p-4 rounded-lg bg-gray-700/50 hover:bg-gray-700 transition-colors">
|
||||
<span class="text-2xl">⚙️</span>
|
||||
<span class="text-sm text-gray-300">System Config</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-2 p-4 rounded-lg bg-gray-700/50 hover:bg-gray-700 transition-colors">
|
||||
<span class="text-2xl">📋</span>
|
||||
<span class="text-sm text-gray-300">Audit Logs</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-2 p-4 rounded-lg bg-gray-700/50 hover:bg-gray-700 transition-colors">
|
||||
<span class="text-2xl">🌐</span>
|
||||
<span class="text-sm text-gray-300">Translations</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity Placeholder -->
|
||||
<div class="bg-gray-800/40 rounded-xl border border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Recent Activity</h3>
|
||||
<div class="text-gray-500 text-center py-8">
|
||||
<span class="text-4xl block mb-3">📡</span>
|
||||
<p>Activity feed will appear here once connected to the backend.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AdminDashboardView.vue — Admin központi áttekintő nézet.
|
||||
* Ideiglenes statisztikai kártyákkal és gyorsműveletekkel.
|
||||
*/
|
||||
</script>
|
||||
327
frontend/src/views/admin/AdminPackagesView.vue
Normal file
327
frontend/src/views/admin/AdminPackagesView.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div class="admin-packages-view">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">{{ $t('admin.packages.title') }}</h2>
|
||||
<p class="text-gray-400 mt-1">{{ $t('admin.packages.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
@click="openCreateModal"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ $t('admin.packages.create_button') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Hidden + Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="showHidden"
|
||||
type="checkbox"
|
||||
class="rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500"
|
||||
@change="loadPackages"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">{{ $t('admin.packages.show_hidden') }}</span>
|
||||
</label>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
|
||||
@click="loadPackages"
|
||||
>
|
||||
{{ $t('admin.packages.refresh') }}
|
||||
</button>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<label class="text-sm text-gray-400">{{ $t('admin.packages.field_type') }}:</label>
|
||||
<select
|
||||
v-model="typeFilter"
|
||||
@change="loadPackages"
|
||||
class="px-3 py-1.5 rounded-lg bg-gray-700 border border-gray-600 text-gray-200 text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">{{ $t('admin.users.filter_all_roles') }}</option>
|
||||
<option value="private">{{ $t('admin.packages.type_private') }}</option>
|
||||
<option value="corporate">{{ $t('admin.packages.type_corporate') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date From Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">{{ $t('admin.packages.date_from_label') || 'Dátum tól' }}:</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="dateFrom"
|
||||
@change="loadPackages"
|
||||
class="px-3 py-1.5 rounded-lg bg-gray-700 border border-gray-600 text-gray-200 text-sm focus:outline-none focus:border-blue-500 [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date Until Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">{{ $t('admin.packages.date_until_label') || 'Dátum ig' }}:</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="dateUntil"
|
||||
@change="loadPackages"
|
||||
class="px-3 py-1.5 rounded-lg bg-gray-700 border border-gray-600 text-gray-200 text-sm focus:outline-none focus:border-blue-500 [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<button
|
||||
v-if="typeFilter || dateFrom || dateUntil"
|
||||
@click="clearFilters"
|
||||
class="px-3 py-1.5 rounded-lg bg-red-800/30 hover:bg-red-800/50 text-red-400 text-sm transition-colors"
|
||||
>
|
||||
✕ {{ $t('admin.users.clear_search') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="store.isLoading" class="text-center py-12 text-gray-400">
|
||||
{{ $t('admin.packages.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="store.error" class="text-center py-12 text-red-400">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="store.tiers.length === 0"
|
||||
class="text-center py-12 text-gray-500"
|
||||
>
|
||||
<span class="text-5xl block mb-4">📦</span>
|
||||
<p class="text-lg">{{ $t('admin.packages.empty') }}</p>
|
||||
<p class="text-sm mt-1">{{ $t('admin.packages.empty_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Package Cards Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="pkg in store.tiers"
|
||||
:key="pkg.id"
|
||||
class="relative bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-blue-500/30 transition-all group"
|
||||
:class="{ 'opacity-70': !isPublic(pkg) }"
|
||||
>
|
||||
<!-- Inactive Badge -->
|
||||
<div
|
||||
v-if="!isPublic(pkg)"
|
||||
class="absolute top-3 right-3 px-2 py-0.5 bg-yellow-900/60 text-yellow-300 border border-yellow-700 rounded-full text-xs font-medium"
|
||||
>
|
||||
{{ $t('admin.packages.badge_inactive') }}
|
||||
</div>
|
||||
|
||||
<!-- Custom Badge -->
|
||||
<div
|
||||
v-if="pkg.is_custom"
|
||||
class="absolute top-3 left-3 px-2 py-0.5 bg-purple-900/60 text-purple-300 border border-purple-700 rounded-full text-xs font-medium"
|
||||
>
|
||||
{{ $t('admin.packages.badge_custom') }}
|
||||
</div>
|
||||
|
||||
<!-- Package Name & Type -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-bold text-white">{{ pkg.rules.display_name || pkg.name }}</h3>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
class="inline-block px-2.5 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="typeBadgeClass(pkg.rules.type)"
|
||||
>
|
||||
{{ pkg.rules.type === 'private' ? $t('admin.packages.type_private') : $t('admin.packages.type_corporate') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 font-mono">{{ pkg.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Info -->
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-400">{{ $t('admin.packages.monthly') }}</span>
|
||||
<span class="text-gray-200 font-medium">{{ formatPrice(pkg.rules.pricing.monthly_price, pkg.rules.pricing.currency) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-400">{{ $t('admin.packages.yearly') }}</span>
|
||||
<span class="text-gray-200 font-medium">{{ formatPrice(pkg.rules.pricing.yearly_price, pkg.rules.pricing.currency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allowances -->
|
||||
<div class="border-t border-gray-700/50 pt-3 mb-4 space-y-1.5">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-400">{{ $t('admin.packages.max_vehicles_label') }}</span>
|
||||
<span class="text-gray-300">{{ pkg.rules.allowances.max_vehicles }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-400">{{ $t('admin.packages.max_garages_label') }}</span>
|
||||
<span class="text-gray-300">{{ pkg.rules.allowances.max_garages }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-400">{{ $t('admin.packages.free_credits_label') }}</span>
|
||||
<span class="text-gray-300">{{ pkg.rules.allowances.monthly_free_credits }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entitlements Chips -->
|
||||
<div class="border-t border-gray-700/50 pt-3 mb-4">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="ent in pkg.rules.entitlements.slice(0, 4)"
|
||||
:key="ent"
|
||||
class="px-2 py-0.5 bg-gray-700/60 text-gray-300 rounded text-xs"
|
||||
>
|
||||
{{ ent }}
|
||||
</span>
|
||||
<span
|
||||
v-if="pkg.rules.entitlements.length > 4"
|
||||
class="px-2 py-0.5 bg-gray-700/60 text-gray-400 rounded text-xs"
|
||||
>
|
||||
+{{ pkg.rules.entitlements.length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pt-3 border-t border-gray-700/50">
|
||||
<button
|
||||
class="flex-1 px-3 py-2 text-sm font-medium text-blue-300 bg-blue-900/30 hover:bg-blue-900/50 border border-blue-700/50 rounded-lg transition-colors"
|
||||
@click="openEditModal(pkg)"
|
||||
>
|
||||
{{ $t('admin.packages.edit_button') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-2 text-sm font-medium text-red-300 bg-red-900/30 hover:bg-red-900/50 border border-red-700/50 rounded-lg transition-colors"
|
||||
@click="confirmDelete(pkg)"
|
||||
>
|
||||
{{ $t('admin.packages.delete_button') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit / Create Modal -->
|
||||
<PackageEditModal
|
||||
v-if="showModal"
|
||||
:package="editingPackage"
|
||||
@close="closeModal"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAdminPackagesStore } from '../../stores/adminPackages'
|
||||
import type { SubscriptionTierItem } from '../../stores/adminPackages'
|
||||
import PackageEditModal from '../../components/admin/PackageEditModal.vue'
|
||||
|
||||
const store = useAdminPackagesStore()
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const showHidden = ref(false)
|
||||
const showModal = ref(false)
|
||||
const editingPackage = ref<SubscriptionTierItem | null>(null)
|
||||
|
||||
// ── Filter State ────────────────────────────────────────────────────────────
|
||||
|
||||
const typeFilter = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateUntil = ref('')
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
loadPackages()
|
||||
})
|
||||
|
||||
// ── Methods ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadPackages() {
|
||||
await store.fetchPackages({
|
||||
include_hidden: showHidden.value,
|
||||
type_filter: typeFilter.value || undefined,
|
||||
date_from: dateFrom.value || undefined,
|
||||
date_until: dateUntil.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
typeFilter.value = ''
|
||||
dateFrom.value = ''
|
||||
dateUntil.value = ''
|
||||
loadPackages()
|
||||
}
|
||||
|
||||
function isPublic(pkg: SubscriptionTierItem): boolean {
|
||||
return pkg.rules.lifecycle?.is_public !== false
|
||||
}
|
||||
|
||||
function typeBadgeClass(type: string): string {
|
||||
if (type === 'corporate') {
|
||||
return 'bg-indigo-900/60 text-indigo-300 border border-indigo-700'
|
||||
}
|
||||
return 'bg-green-900/60 text-green-300 border border-green-700'
|
||||
}
|
||||
|
||||
function formatPrice(price: number, currency: string): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
// ── Modal Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
function openCreateModal() {
|
||||
editingPackage.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(pkg: SubscriptionTierItem) {
|
||||
editingPackage.value = pkg
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingPackage.value = null
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
closeModal()
|
||||
loadPackages()
|
||||
}
|
||||
|
||||
// ── Delete Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
async function confirmDelete(pkg: SubscriptionTierItem) {
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to deactivate the package "${pkg.name}"? This will set it to inactive (is_public = false).`,
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await store.deletePackage(pkg.id)
|
||||
await loadPackages()
|
||||
} catch (err: any) {
|
||||
alert(store.error || 'Failed to deactivate package')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-packages-view {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
349
frontend/src/views/admin/AdminServicesView.vue
Normal file
349
frontend/src/views/admin/AdminServicesView.vue
Normal file
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div class="admin-services-view">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">{{ $t('admin.services.title') }}</h2>
|
||||
<p class="text-gray-400 mt-1">{{ $t('admin.services.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
@click="openCreateModal"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ $t('admin.services.create_button') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
|
||||
@click="loadServices"
|
||||
>
|
||||
{{ $t('admin.services.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="store.isLoading" class="text-center py-12 text-gray-400">
|
||||
{{ $t('admin.services.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="store.error" class="text-center py-12 text-red-400">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="store.services.length === 0"
|
||||
class="text-center py-12 text-gray-500"
|
||||
>
|
||||
<span class="text-5xl block mb-4">🛠️</span>
|
||||
<p class="text-lg">{{ $t('admin.services.empty') }}</p>
|
||||
<p class="text-sm mt-1">{{ $t('admin.services.empty_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Service Cards Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="svc in store.services"
|
||||
:key="svc.id"
|
||||
class="relative bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-blue-500/30 transition-all group"
|
||||
:class="{ 'opacity-70': !svc.is_active }"
|
||||
>
|
||||
<!-- Inactive Badge -->
|
||||
<div
|
||||
v-if="!svc.is_active"
|
||||
class="absolute top-3 right-3 px-2 py-0.5 bg-yellow-900/60 text-yellow-300 border border-yellow-700 rounded-full text-xs font-medium"
|
||||
>
|
||||
{{ $t('admin.services.badge_inactive') }}
|
||||
</div>
|
||||
|
||||
<!-- Service Name & Description -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-bold text-white">{{ svc.name }}</h3>
|
||||
<!-- Service Code as small secondary badge -->
|
||||
<span class="inline-block mt-1 px-2 py-0.5 bg-gray-700/50 text-gray-500 border border-gray-600/50 rounded text-xs font-mono">
|
||||
{{ svc.service_code }}
|
||||
</span>
|
||||
<p v-if="svc.description" class="text-sm text-gray-400 mt-2 line-clamp-2">
|
||||
{{ svc.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Credit Cost -->
|
||||
<div class="border-t border-gray-700/50 pt-3 mb-4">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-400">{{ $t('admin.services.credit_cost_label') }}</span>
|
||||
<span class="text-gray-200 font-medium">{{ svc.credit_cost }} {{ $t('admin.services.credit_unit') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pt-3 border-t border-gray-700/50">
|
||||
<button
|
||||
class="flex-1 px-3 py-2 text-sm font-medium text-blue-300 bg-blue-900/30 hover:bg-blue-900/50 border border-blue-700/50 rounded-lg transition-colors"
|
||||
@click="openEditModal(svc)"
|
||||
>
|
||||
{{ $t('admin.services.edit_button') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors"
|
||||
:class="svc.is_active
|
||||
? 'text-red-300 bg-red-900/30 hover:bg-red-900/50 border border-red-700/50'
|
||||
: 'text-green-300 bg-green-900/30 hover:bg-green-900/50 border border-green-700/50'"
|
||||
@click="toggleActive(svc)"
|
||||
>
|
||||
{{ svc.is_active ? $t('admin.services.deactivate_button') : $t('admin.services.activate_button') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit / Create Modal -->
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-lg mx-4 shadow-2xl">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-700">
|
||||
<h3 class="text-lg font-bold text-white">
|
||||
{{ editingService ? $t('admin.services.modal_edit_title') : $t('admin.services.modal_create_title') }}
|
||||
</h3>
|
||||
<button
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
@click="closeModal"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<!-- Service Code (only for create) -->
|
||||
<div v-if="!editingService">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1">
|
||||
{{ $t('admin.services.field_code') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.service_code"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
||||
:placeholder="$t('admin.services.field_code_placeholder')"
|
||||
/>
|
||||
<p v-if="formErrors.service_code" class="text-red-400 text-xs mt-1">{{ formErrors.service_code }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1">
|
||||
{{ $t('admin.services.field_name') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
||||
:placeholder="$t('admin.services.field_name_placeholder')"
|
||||
/>
|
||||
<p v-if="formErrors.name" class="text-red-400 text-xs mt-1">{{ formErrors.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1">
|
||||
{{ $t('admin.services.field_description') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none"
|
||||
:placeholder="$t('admin.services.field_description_placeholder')"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Credit Cost (no spinners) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1">
|
||||
{{ $t('admin.services.field_credit_cost') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.credit_cost"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none
|
||||
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal 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 text-sm font-medium text-gray-300 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ $t('admin.services.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 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="store.isLoading"
|
||||
@click="saveService"
|
||||
>
|
||||
<span v-if="store.isLoading">{{ $t('admin.services.saving') }}</span>
|
||||
<span v-else>{{ editingService ? $t('admin.services.save_changes') : $t('admin.services.create') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useAdminServicesStore } from '../../stores/adminServices'
|
||||
import type { ServiceCatalogItem } from '../../stores/adminServices'
|
||||
|
||||
const store = useAdminServicesStore()
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingService = ref<ServiceCatalogItem | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
service_code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
credit_cost: 0,
|
||||
})
|
||||
|
||||
const formErrors = reactive({
|
||||
service_code: '',
|
||||
name: '',
|
||||
})
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
loadServices()
|
||||
})
|
||||
|
||||
// ── Methods ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadServices() {
|
||||
await store.fetchServices()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.service_code = ''
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.credit_cost = 0
|
||||
formErrors.service_code = ''
|
||||
formErrors.name = ''
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
let valid = true
|
||||
formErrors.service_code = ''
|
||||
formErrors.name = ''
|
||||
|
||||
if (!editingService) {
|
||||
if (!form.service_code || form.service_code.trim().length < 3) {
|
||||
formErrors.service_code = 'Service code must be at least 3 characters'
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!form.name || form.name.trim().length < 2) {
|
||||
formErrors.name = 'Name must be at least 2 characters'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
// ── Modal Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
function openCreateModal() {
|
||||
editingService.value = null
|
||||
resetForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(svc: ServiceCatalogItem) {
|
||||
editingService.value = svc
|
||||
form.service_code = svc.service_code
|
||||
form.name = svc.name
|
||||
form.description = svc.description || ''
|
||||
form.credit_cost = svc.credit_cost
|
||||
formErrors.service_code = ''
|
||||
formErrors.name = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingService.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
async function saveService() {
|
||||
if (!validateForm()) return
|
||||
|
||||
try {
|
||||
if (editingService.value) {
|
||||
await store.updateService(editingService.value.id, {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
credit_cost: form.credit_cost,
|
||||
})
|
||||
} else {
|
||||
await store.createService({
|
||||
service_code: form.service_code.trim().toUpperCase(),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
credit_cost: form.credit_cost,
|
||||
is_active: true,
|
||||
})
|
||||
}
|
||||
closeModal()
|
||||
await loadServices()
|
||||
} catch (err: any) {
|
||||
// Error is already set in the store
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(svc: ServiceCatalogItem) {
|
||||
try {
|
||||
await store.updateService(svc.id, { is_active: !svc.is_active })
|
||||
await loadServices()
|
||||
} catch (err: any) {
|
||||
// Error is already set in the store
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-services-view {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
input[type='number']::-webkit-inner-spin-button,
|
||||
input[type='number']::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
594
frontend/src/views/admin/AdminUsersView.vue
Normal file
594
frontend/src/views/admin/AdminUsersView.vue
Normal file
@@ -0,0 +1,594 @@
|
||||
<template>
|
||||
<div class="admin-users-view">
|
||||
<!-- Header: Search & Filters -->
|
||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||
<!-- Search Category Dropdown -->
|
||||
<select
|
||||
v-model="searchCategory"
|
||||
class="px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">{{ $t('admin.users.search_category_all') }}</option>
|
||||
<option value="email">{{ $t('admin.users.search_category_email') }}</option>
|
||||
<option value="name">{{ $t('admin.users.search_category_name') }}</option>
|
||||
<option value="phone">{{ $t('admin.users.search_category_phone') }}</option>
|
||||
<option value="id">{{ $t('admin.users.search_category_id') }}</option>
|
||||
<option value="address">{{ $t('admin.users.search_category_address') }}</option>
|
||||
</select>
|
||||
|
||||
<!-- Search Input + Clear Button + Search Button -->
|
||||
<div class="relative flex-1 min-w-[200px] flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('admin.users.search_placeholder')"
|
||||
class="w-full px-4 py-2 pr-10 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"
|
||||
@keyup.enter="triggerSearch"
|
||||
/>
|
||||
<!-- Clear (X) button -->
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-200 transition-colors rounded-full hover:bg-gray-600"
|
||||
@click="clearSearch"
|
||||
:title="$t('admin.users.clear_search')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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>
|
||||
<button
|
||||
class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
@click="triggerSearch"
|
||||
:title="$t('admin.users.search_button')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Role Filter -->
|
||||
<select
|
||||
v-model="roleFilter"
|
||||
class="px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="applyFilters"
|
||||
>
|
||||
<option value="">{{ $t('admin.users.filter_all_roles') }}</option>
|
||||
<option value="superadmin">Superadmin</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="region_admin">Region Admin</option>
|
||||
<option value="country_admin">Country Admin</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="user">User</option>
|
||||
<option value="service_owner">Service Owner</option>
|
||||
<option value="fleet_manager">Fleet Manager</option>
|
||||
<option value="driver">Driver</option>
|
||||
</select>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="applyFilters"
|
||||
>
|
||||
<option value="">{{ $t('admin.users.filter_all_status') }}</option>
|
||||
<option value="active">{{ $t('admin.users.status_active') }}</option>
|
||||
<option value="inactive">{{ $t('admin.users.status_inactive') }}</option>
|
||||
<option value="deleted">{{ $t('admin.users.status_deleted') }}</option>
|
||||
</select>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
@click="loadUsers"
|
||||
>
|
||||
{{ $t('admin.users.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Action Bar (visible when at least 1 user is selected) -->
|
||||
<div
|
||||
v-if="selectedUserIds.length > 0"
|
||||
class="sticky top-[64px] z-30 flex flex-wrap items-center gap-3 px-4 py-3 mb-4 bg-gray-800 border border-gray-600 rounded-lg shadow-lg"
|
||||
>
|
||||
<span class="text-sm text-gray-300">
|
||||
{{ $t('admin.users.n_selected', { count: selectedUserIds.length }) }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="bulkLoading"
|
||||
@click="executeBulkAction('ban')"
|
||||
>
|
||||
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_ban') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="bulkLoading"
|
||||
@click="executeBulkAction('unban')"
|
||||
>
|
||||
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_unban') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="bulkLoading"
|
||||
@click="executeBulkAction('soft_delete')"
|
||||
>
|
||||
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_soft_delete') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="bulkLoading"
|
||||
@click="executeBulkAction('restore')"
|
||||
>
|
||||
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_restore') }}
|
||||
</button>
|
||||
|
||||
<!-- Hard Delete: Only visible for Superadmin -->
|
||||
<button
|
||||
v-if="authStore.user?.role === 'superadmin'"
|
||||
class="px-3 py-1.5 bg-red-800 hover:bg-red-900 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="bulkLoading"
|
||||
@click="executeBulkAction('hard_delete')"
|
||||
>
|
||||
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_hard_delete') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-3 py-1.5 bg-gray-600 hover:bg-gray-500 text-white text-sm rounded-lg transition-colors ml-auto"
|
||||
@click="clearSelection"
|
||||
>
|
||||
{{ $t('admin.users.clear_selection') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="store.isLoading" class="text-center py-8 text-gray-400">
|
||||
{{ $t('admin.users.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="store.error" class="text-center py-8 text-red-400">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 text-left text-sm text-gray-400 uppercase">
|
||||
<th class="py-3 px-4 w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
:indeterminate="isIndeterminate"
|
||||
@change="toggleSelectAll"
|
||||
class="rounded bg-gray-700 border-gray-500"
|
||||
/>
|
||||
</th>
|
||||
<th class="py-3 px-4">ID</th>
|
||||
<th class="py-3 px-4">{{ $t('admin.users.col_email') }}</th>
|
||||
<th class="py-3 px-4">{{ $t('admin.users.col_name') }}</th>
|
||||
<th class="py-3 px-4">{{ $t('admin.users.col_phone') }}</th>
|
||||
<th class="py-3 px-4">{{ $t('admin.users.col_address') }}</th>
|
||||
<th class="py-3 px-4">{{ $t('admin.users.col_role') }}</th>
|
||||
<th class="py-3 px-4">{{ $t('admin.users.col_status') }}</th>
|
||||
<th class="py-3 px-4">{{ $t('admin.users.col_subscription') }}</th>
|
||||
<th class="py-3 px-4">{{ $t('admin.users.col_region') }}</th>
|
||||
<th class="py-3 px-4">{{ $t('admin.users.col_created') }}</th>
|
||||
<th class="py-3 px-4 w-16">{{ $t('admin.users.col_actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in store.users"
|
||||
:key="user.id"
|
||||
class="border-b border-gray-700/50 hover:bg-gray-700/30 transition-colors"
|
||||
>
|
||||
<td class="py-3 px-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedUserIds.includes(user.id)"
|
||||
@change="toggleUser(user.id)"
|
||||
class="rounded bg-gray-700 border-gray-500"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-gray-400 text-sm">{{ user.id }}</td>
|
||||
<td class="py-3 px-4">{{ user.email }}</td>
|
||||
<td class="py-3 px-4">{{ user.first_name || '' }} {{ user.last_name || '' }}</td>
|
||||
<td class="py-3 px-4 text-sm text-gray-300">{{ user.phone || '-' }}</td>
|
||||
<td class="py-3 px-4 text-sm text-gray-300">{{ user.address || '-' }}</td>
|
||||
<td class="py-3 px-4">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="roleBadgeClass(user.role)"
|
||||
>
|
||||
{{ user.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="statusBadgeClass(user)"
|
||||
>
|
||||
{{ statusLabel(user) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-sm text-gray-300">{{ user.subscription_plan }}</td>
|
||||
<td class="py-3 px-4 text-sm text-gray-400">{{ user.region_code }}</td>
|
||||
<td class="py-3 px-4 text-sm text-gray-400">{{ formatDate(user.created_at) }}</td>
|
||||
<td class="py-3 px-4 relative">
|
||||
<!-- Kebab Menu -->
|
||||
<div class="relative inline-block text-left">
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-600 transition-colors text-gray-400 hover:text-gray-200"
|
||||
@click.stop="toggleKebab(user.id)"
|
||||
:title="$t('admin.users.actions')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Dropdown -->
|
||||
<div
|
||||
v-if="openKebabId === user.id"
|
||||
class="absolute right-0 mt-1 w-56 bg-gray-800 border border-gray-600 rounded-lg shadow-xl z-40 py-1"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
@click="editUser(user)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
{{ $t('admin.users.action_edit') }}
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
@click="sendPasswordReset(user)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
{{ $t('admin.users.action_password_reset') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!user.is_deleted && user.is_active"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
@click="individualAction(user.id, 'ban')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
{{ $t('admin.users.action_ban') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!user.is_deleted && !user.is_active"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
@click="individualAction(user.id, 'unban')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
{{ $t('admin.users.action_unban') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!user.is_deleted"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
@click="individualAction(user.id, 'soft_delete')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ $t('admin.users.action_soft_delete') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="user.is_deleted"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
@click="individualAction(user.id, 'restore')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
{{ $t('admin.users.action_restore') }}
|
||||
</button>
|
||||
<div class="border-t border-gray-600 my-1"></div>
|
||||
<button
|
||||
v-if="authStore.user?.role === 'superadmin'"
|
||||
class="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
@click="individualAction(user.id, 'hard_delete')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ $t('admin.users.action_hard_delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Empty State -->
|
||||
<tr v-if="store.users.length === 0 && !store.isLoading">
|
||||
<td :colspan="12" class="py-12 text-center">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p class="text-gray-400 text-lg">
|
||||
{{ searchQuery ? $t('admin.users.empty_search') : $t('admin.users.empty_no_users') }}
|
||||
</p>
|
||||
<p v-if="searchQuery" class="text-gray-500 text-sm">
|
||||
{{ $t('admin.users.empty_search_hint') }}
|
||||
</p>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="mt-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg transition-colors text-sm"
|
||||
@click="clearSearch"
|
||||
>
|
||||
{{ $t('admin.users.clear_search') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination & Stats Footer -->
|
||||
<div class="flex items-center justify-between mt-6 pt-4 border-t border-gray-700">
|
||||
<div class="text-sm text-gray-400">
|
||||
{{ $t('admin.users.showing_of', { showing: store.users.length, total: store.total }) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
:disabled="currentSkip === 0"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors"
|
||||
@click="prevPage"
|
||||
>
|
||||
{{ $t('admin.users.pagination_prev') }}
|
||||
</button>
|
||||
<span class="text-sm text-gray-400">
|
||||
{{ $t('admin.users.pagination_page', { current: currentPage, total: totalPages }) }}
|
||||
</span>
|
||||
<button
|
||||
:disabled="currentSkip + currentLimit >= store.total"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors"
|
||||
@click="nextPage"
|
||||
>
|
||||
{{ $t('admin.users.pagination_next') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAdminUsersStore } from '../../stores/adminUsers'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import api from '../../api/axios'
|
||||
|
||||
const store = useAdminUsersStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Filter State ──────────────────────────────────────────────────
|
||||
const searchQuery = ref('')
|
||||
const searchCategory = ref('all')
|
||||
const roleFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
|
||||
// ── Pagination State ──────────────────────────────────────────────
|
||||
const currentSkip = ref(0)
|
||||
const currentLimit = ref(50)
|
||||
|
||||
// ── Selection State ───────────────────────────────────────────────
|
||||
const selectedUserIds = ref<number[]>([])
|
||||
const bulkLoading = ref(false)
|
||||
|
||||
// ── Kebab Menu State ──────────────────────────────────────────────
|
||||
const openKebabId = ref<number | null>(null)
|
||||
|
||||
// ── Computed ──────────────────────────────────────────────────────
|
||||
const currentPage = computed(() => Math.floor(currentSkip.value / currentLimit.value) + 1)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(store.total / currentLimit.value)))
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return store.users.length > 0 && store.users.every((u) => selectedUserIds.value.includes(u.id))
|
||||
})
|
||||
|
||||
const isIndeterminate = computed(() => {
|
||||
const selected = store.users.filter((u) => selectedUserIds.value.includes(u.id)).length
|
||||
return selected > 0 && selected < store.users.length
|
||||
})
|
||||
|
||||
// ── Methods ───────────────────────────────────────────────────────
|
||||
|
||||
function roleBadgeClass(role: string): string {
|
||||
const map: Record<string, string> = {
|
||||
superadmin: 'bg-purple-900/60 text-purple-300 border border-purple-700',
|
||||
admin: 'bg-blue-900/60 text-blue-300 border border-blue-700',
|
||||
region_admin: 'bg-indigo-900/60 text-indigo-300 border border-indigo-700',
|
||||
country_admin: 'bg-cyan-900/60 text-cyan-300 border border-cyan-700',
|
||||
moderator: 'bg-green-900/60 text-green-300 border border-green-700',
|
||||
user: 'bg-gray-700/60 text-gray-300 border border-gray-600',
|
||||
service_owner: 'bg-orange-900/60 text-orange-300 border border-orange-700',
|
||||
fleet_manager: 'bg-teal-900/60 text-teal-300 border border-teal-700',
|
||||
driver: 'bg-slate-700/60 text-slate-300 border border-slate-600',
|
||||
}
|
||||
return map[role] || 'bg-gray-700/60 text-gray-300 border border-gray-600'
|
||||
}
|
||||
|
||||
function statusBadgeClass(user: { is_active: boolean; is_deleted: boolean }): string {
|
||||
if (user.is_deleted) return 'bg-red-900/60 text-red-300 border border-red-700'
|
||||
if (user.is_active) return 'bg-green-900/60 text-green-300 border border-green-700'
|
||||
return 'bg-yellow-900/60 text-yellow-300 border border-yellow-700'
|
||||
}
|
||||
|
||||
function statusLabel(user: { is_active: boolean; is_deleted: boolean }): string {
|
||||
if (user.is_deleted) return 'Deleted'
|
||||
if (user.is_active) return 'Active'
|
||||
return 'Inactive'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function triggerSearch() {
|
||||
currentSkip.value = 0
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
searchCategory.value = 'all'
|
||||
currentSkip.value = 0
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
currentSkip.value = 0
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const params: Record<string, any> = {
|
||||
skip: currentSkip.value,
|
||||
limit: currentLimit.value,
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
params.search_term = searchQuery.value
|
||||
params.search_category = searchCategory.value
|
||||
}
|
||||
if (roleFilter.value) params.role = roleFilter.value
|
||||
if (statusFilter.value === 'active') params.is_active = true
|
||||
else if (statusFilter.value === 'inactive') params.is_active = false
|
||||
else if (statusFilter.value === 'deleted') params.is_deleted = true
|
||||
|
||||
await store.fetchUsers(params)
|
||||
selectedUserIds.value = []
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentSkip.value > 0) {
|
||||
currentSkip.value = Math.max(0, currentSkip.value - currentLimit.value)
|
||||
loadUsers()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentSkip.value + currentLimit.value < store.total) {
|
||||
currentSkip.value += currentLimit.value
|
||||
loadUsers()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection Methods ─────────────────────────────────────────────
|
||||
|
||||
function toggleUser(userId: number) {
|
||||
const idx = selectedUserIds.value.indexOf(userId)
|
||||
if (idx === -1) {
|
||||
selectedUserIds.value.push(userId)
|
||||
} else {
|
||||
selectedUserIds.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
selectedUserIds.value = []
|
||||
} else {
|
||||
selectedUserIds.value = store.users.map((u) => u.id)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedUserIds.value = []
|
||||
}
|
||||
|
||||
// ── Kebab Menu ────────────────────────────────────────────────────
|
||||
|
||||
function toggleKebab(userId: number) {
|
||||
openKebabId.value = openKebabId.value === userId ? null : userId
|
||||
}
|
||||
|
||||
// Close kebab on outside click
|
||||
function handleClickOutside() {
|
||||
openKebabId.value = null
|
||||
}
|
||||
|
||||
// ── Individual Actions ────────────────────────────────────────────
|
||||
|
||||
function editUser(user: any) {
|
||||
console.log('Edit user:', user.id, user.email)
|
||||
openKebabId.value = null
|
||||
// Future: open edit modal
|
||||
}
|
||||
|
||||
async function sendPasswordReset(user: any) {
|
||||
openKebabId.value = null
|
||||
if (!window.confirm(`Send password reset email to ${user.email}?`)) return
|
||||
try {
|
||||
await api.post('/auth/password-reset/initiate', { email: user.email })
|
||||
alert(`Password reset email sent to ${user.email}`)
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to send password reset email')
|
||||
}
|
||||
}
|
||||
|
||||
async function individualAction(userId: number, action: string) {
|
||||
openKebabId.value = null
|
||||
const actionLabel = action.replace('_', ' ')
|
||||
if (!window.confirm(`Are you sure you want to ${actionLabel} user #${userId}?`)) return
|
||||
|
||||
try {
|
||||
await store.bulkAction([userId], action)
|
||||
alert(`User #${userId} ${actionLabel} successful`)
|
||||
} catch (err: any) {
|
||||
alert(store.error || `Failed to ${actionLabel} user #${userId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bulk Actions ──────────────────────────────────────────────────
|
||||
|
||||
async function executeBulkAction(action: string) {
|
||||
if (selectedUserIds.value.length === 0) return
|
||||
|
||||
const actionLabel = action.replace('_', ' ')
|
||||
if (!window.confirm(`Are you sure you want to ${actionLabel} ${selectedUserIds.value.length} user(s)?`)) return
|
||||
|
||||
bulkLoading.value = true
|
||||
try {
|
||||
const result = await store.bulkAction(selectedUserIds.value, action)
|
||||
alert(result.message)
|
||||
selectedUserIds.value = []
|
||||
} catch (err: any) {
|
||||
alert(store.error || `Failed to execute ${action}`)
|
||||
} finally {
|
||||
bulkLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-users-view {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
@@ -205,6 +205,17 @@
|
||||
<button class="w-full px-4 py-3 rounded-xl bg-amber-50/80 hover:bg-amber-100/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-amber-200">
|
||||
{{ t('company.quickSchedule') || 'Schedule Service' }}
|
||||
</button>
|
||||
<!-- Organization Settings Button -->
|
||||
<button
|
||||
@click="showSettingsModal = true"
|
||||
class="w-full px-4 py-3 rounded-xl bg-emerald-50/80 hover:bg-emerald-100/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-emerald-200 flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{{ t('company.settingsTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Recent activity mini-list -->
|
||||
<div class="mt-4 pt-3 border-t border-slate-200">
|
||||
@@ -224,15 +235,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organization Settings Modal -->
|
||||
<OrganizationSettingsModal
|
||||
v-if="showSettingsModal"
|
||||
@close="showSettingsModal = false"
|
||||
@saved="showSettingsModal = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import GlassCard from '../../components/ui/GlassCard.vue'
|
||||
import OrganizationSettingsModal from '../../components/organization/OrganizationSettingsModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Organization Settings Modal ──
|
||||
const showSettingsModal = ref(false)
|
||||
|
||||
// ── Placeholder notifications ──
|
||||
const notifications = ref([
|
||||
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
|
||||
|
||||
@@ -161,6 +161,123 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Smart Company Claiming / Joining Panels ── -->
|
||||
<!-- Active Exists: Company is already registered -->
|
||||
<template v-if="companyStatus === 'active_exists'">
|
||||
<div class="rounded-xl border border-[#D4AF37]/30 bg-[#D4AF37]/10 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-[#D4AF37] mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-[#D4AF37] font-medium mb-2">{{ t('company.activeExistsPanel') }}</p>
|
||||
<button
|
||||
@click="sendJoinRequest"
|
||||
:disabled="isJoinRequestLoading"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-[#D4AF37]/20 px-4 py-2 text-sm text-[#D4AF37] font-medium border border-[#D4AF37]/30 hover:bg-[#D4AF37]/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
<svg v-if="isJoinRequestLoading" class="animate-spin h-4 w-4" 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.joinRequestButton') }}
|
||||
</button>
|
||||
<p v-if="joinRequestSent" class="text-xs text-[#D4AF37]/70 mt-2">{{ t('company.joinRequestSent') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Orphaned: Company is inactive, can be claimed -->
|
||||
<template v-if="companyStatus === 'orphaned'">
|
||||
<!-- Claim Step 1: Request claim code -->
|
||||
<template v-if="claimStep === 0">
|
||||
<div class="rounded-xl border border-[#00E5A0]/30 bg-[#00E5A0]/10 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-[#00E5A0] mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-[#00E5A0] font-medium mb-2">{{ t('company.orphanedPanel') }}</p>
|
||||
<button
|
||||
@click="requestClaimCode"
|
||||
:disabled="isClaimRequestLoading"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-[#00E5A0]/20 px-4 py-2 text-sm text-[#00E5A0] font-medium border border-[#00E5A0]/30 hover:bg-[#00E5A0]/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
<svg v-if="isClaimRequestLoading" class="animate-spin h-4 w-4" 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.claimRequestButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Claim Step 2: Verify OTP -->
|
||||
<template v-if="claimStep === 1">
|
||||
<div class="rounded-xl border border-[#00E5A0]/30 bg-[#00E5A0]/10 p-4">
|
||||
<p class="text-sm text-[#00E5A0] font-medium mb-3">{{ t('company.claimCodeSent') }}</p>
|
||||
<p class="text-xs text-white/60 mb-3">{{ t('company.claimVerifyTitle') }}</p>
|
||||
<div class="flex gap-2 items-end">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="claimOtp"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="000000"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all text-sm tracking-widest text-center"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="verifyClaimCode"
|
||||
:disabled="isClaimVerifyLoading || claimOtp.length !== 6"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-[#00E5A0]/20 px-4 py-2.5 text-sm text-[#00E5A0] font-medium border border-[#00E5A0]/30 hover:bg-[#00E5A0]/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
<svg v-if="isClaimVerifyLoading" class="animate-spin h-4 w-4" 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.claimVerifyButton') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="claimError" class="text-xs text-red-400 mt-2">{{ claimError }}</p>
|
||||
<p v-if="claimSuccess" class="text-xs text-[#00E5A0] mt-2">{{ t('company.claimSuccess') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Manual Fallback -->
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4 mt-3">
|
||||
<p class="text-sm text-white/80 font-medium mb-1">{{ t('company.manualFallbackTitle') }}</p>
|
||||
<p class="text-xs text-white/50 mb-3">{{ t('company.manualFallbackDescription') }}</p>
|
||||
<div class="flex gap-2 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-white/50 mb-1">{{ t('company.uploadDocumentLabel') }}</label>
|
||||
<input
|
||||
ref="manualFileInput"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
@change="onManualFileSelected"
|
||||
class="w-full text-xs text-white/60 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-medium file:bg-white/10 file:text-white/80 hover:file:bg-white/20 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="submitManualClaim"
|
||||
:disabled="isManualSubmitting || !manualFile"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2.5 text-sm text-white/80 font-medium border border-white/20 hover:bg-white/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
<svg v-if="isManualSubmitting" class="animate-spin h-4 w-4" 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.manualSubmitButton') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="manualSubmitSuccess" class="text-xs text-[#00E5A0] mt-2">{{ t('company.manualSubmitSuccess') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -500,6 +617,22 @@ const isTaxLookupLoading = ref(false)
|
||||
const taxLookedUp = ref(false)
|
||||
const isDuplicateCompany = ref(false)
|
||||
|
||||
// ── Smart Company Claiming / Joining State ──
|
||||
const companyStatus = ref<string | null>(null) // 'active_exists' | 'orphaned' | null
|
||||
const companyId = ref<number | null>(null) // ID of the existing company
|
||||
const isJoinRequestLoading = ref(false)
|
||||
const joinRequestSent = ref(false)
|
||||
const claimStep = ref(0) // 0 = request code, 1 = verify OTP
|
||||
const isClaimRequestLoading = ref(false)
|
||||
const claimOtp = ref('')
|
||||
const isClaimVerifyLoading = ref(false)
|
||||
const claimError = ref('')
|
||||
const claimSuccess = ref(false)
|
||||
const manualFileInput = ref<HTMLInputElement | null>(null)
|
||||
const manualFile = ref<File | null>(null)
|
||||
const isManualSubmitting = ref(false)
|
||||
const manualSubmitSuccess = ref(false)
|
||||
|
||||
// ── CorpOnboardIn Form ──
|
||||
const form = reactive({
|
||||
full_name: '',
|
||||
@@ -619,11 +752,21 @@ function validateStep(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Reset duplicate flag when tax number changes ──
|
||||
// ── Reset flags when tax number changes ──
|
||||
function onTaxNumberChange() {
|
||||
if (isDuplicateCompany.value) {
|
||||
isDuplicateCompany.value = false
|
||||
}
|
||||
// Reset claiming/joining state
|
||||
companyStatus.value = null
|
||||
companyId.value = null
|
||||
joinRequestSent.value = false
|
||||
claimStep.value = 0
|
||||
claimOtp.value = ''
|
||||
claimError.value = ''
|
||||
claimSuccess.value = false
|
||||
manualSubmitSuccess.value = false
|
||||
manualFile.value = null
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
@@ -670,10 +813,40 @@ async function lookupTaxNumber() {
|
||||
isTaxLookupLoading.value = true
|
||||
errorMessage.value = null
|
||||
isDuplicateCompany.value = false
|
||||
// Reset claiming/joining state
|
||||
companyStatus.value = null
|
||||
companyId.value = null
|
||||
joinRequestSent.value = false
|
||||
claimStep.value = 0
|
||||
claimOtp.value = ''
|
||||
claimError.value = ''
|
||||
claimSuccess.value = false
|
||||
manualSubmitSuccess.value = false
|
||||
manualFile.value = null
|
||||
try {
|
||||
const res = await api.get('/organizations/lookup-tax/' + form.tax_number)
|
||||
const data = res.data
|
||||
// Auto-fill form fields from response
|
||||
|
||||
// Check for smart company claiming/joining statuses
|
||||
if (data.status === 'active_exists') {
|
||||
companyStatus.value = 'active_exists'
|
||||
companyId.value = data.id || null
|
||||
// Auto-fill company name for display
|
||||
if (data.full_name) form.full_name = data.full_name
|
||||
if (data.name) form.name = data.name
|
||||
return // Stop — don't reveal onboarding fields
|
||||
}
|
||||
|
||||
if (data.status === 'orphaned') {
|
||||
companyStatus.value = 'orphaned'
|
||||
companyId.value = data.id || null
|
||||
// Auto-fill company name for display
|
||||
if (data.full_name) form.full_name = data.full_name
|
||||
if (data.name) form.name = data.name
|
||||
return // Stop — don't reveal onboarding fields
|
||||
}
|
||||
|
||||
// Normal flow: company not found, proceed with onboarding
|
||||
if (data.full_name) form.full_name = data.full_name
|
||||
if (data.name) {
|
||||
form.name = data.name
|
||||
@@ -706,6 +879,96 @@ async function lookupTaxNumber() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Smart Company Claiming / Joining Functions ──
|
||||
|
||||
/**
|
||||
* Send a join request to an active company's administrator.
|
||||
*/
|
||||
async function sendJoinRequest() {
|
||||
if (!companyId.value) return
|
||||
isJoinRequestLoading.value = true
|
||||
try {
|
||||
await api.post(`/organizations/${companyId.value}/join-request`)
|
||||
joinRequestSent.value = true
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.response?.data?.detail || t('company.lookupError')
|
||||
} finally {
|
||||
isJoinRequestLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a claim code for an orphaned company.
|
||||
* The code is sent to the company's official email.
|
||||
*/
|
||||
async function requestClaimCode() {
|
||||
if (!companyId.value) return
|
||||
isClaimRequestLoading.value = true
|
||||
claimError.value = ''
|
||||
try {
|
||||
await api.post(`/organizations/${companyId.value}/claim/request`)
|
||||
claimStep.value = 1 // Move to OTP verification step
|
||||
} catch (err: any) {
|
||||
claimError.value = err.response?.data?.detail || t('company.claimError')
|
||||
} finally {
|
||||
isClaimRequestLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the claim code and complete the company claim.
|
||||
*/
|
||||
async function verifyClaimCode() {
|
||||
if (!companyId.value || claimOtp.value.length !== 6) return
|
||||
isClaimVerifyLoading.value = true
|
||||
claimError.value = ''
|
||||
try {
|
||||
await api.post(`/organizations/${companyId.value}/claim/verify`, {
|
||||
otp: claimOtp.value,
|
||||
})
|
||||
claimSuccess.value = true
|
||||
// Refresh organizations and navigate to company garage
|
||||
await authStore.fetchMyOrganizations()
|
||||
setTimeout(() => {
|
||||
router.push('/company/garage')
|
||||
}, 1500)
|
||||
} catch (err: any) {
|
||||
claimError.value = err.response?.data?.detail || t('company.claimError')
|
||||
} finally {
|
||||
isClaimVerifyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file selection for manual claim.
|
||||
*/
|
||||
function onManualFileSelected(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
manualFile.value = target.files[0]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a manual claim with uploaded documentation.
|
||||
*/
|
||||
async function submitManualClaim() {
|
||||
if (!companyId.value || !manualFile.value) return
|
||||
isManualSubmitting.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('document', manualFile.value)
|
||||
await api.post(`/organizations/${companyId.value}/claim/manual`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
manualSubmitSuccess.value = true
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.response?.data?.detail || t('company.claimError')
|
||||
} finally {
|
||||
isManualSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit ──
|
||||
async function handleSubmit() {
|
||||
if (!validateStep()) return
|
||||
|
||||
Reference in New Issue
Block a user