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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -50,6 +50,28 @@
</svg>
{{ t('header.profile') }}
</button>
<!-- Admin Center only for admin/superadmin roles -->
<button
v-if="isAdmin"
@click="goToAdmin"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-yellow-400/80 transition-all duration-150 hover:bg-white/5 hover:text-yellow-300"
>
<svg
class="h-4 w-4 text-yellow-400/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
{{ t('header.adminCenter') }}
</button>
</div>
<!-- Divider -->
@@ -120,12 +142,26 @@ const initials = computed(() => {
return '?'
})
// ── Admin check: only admin/superadmin/region_admin/country_admin/moderator ──
const isAdmin = computed(() => {
const role = authStore.user?.role
if (!role) return false
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
return adminRoles.includes(role)
})
// ── Navigate to Profile ────────────────────────────────────────────
function goToProfile() {
isOpen.value = false
router.push('/profile')
}
// ── Navigate to Admin Center ───────────────────────────────────────
function goToAdmin() {
isOpen.value = false
router.push('/admin')
}
// ── Logout handler ─────────────────────────────────────────────────
function handleLogout() {
isOpen.value = false

View File

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

View File

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

View File

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

View File

@@ -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}',
},
},
}

View File

@@ -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}',
},
},
}

View File

@@ -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'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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' },

View File

@@ -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