pénzügyi modul továbbfejlesztése (csomagkezelés)

This commit is contained in:
Roo
2026-07-29 09:46:10 +00:00
parent 6f28d3e70d
commit 75904cd2f8
50 changed files with 5851 additions and 312 deletions

View File

@@ -21,7 +21,7 @@ api.interceptors.request.use(
}
)
// Response interceptor: handle 401 Unauthorized (expired/invalid token)
// Response interceptor: handle 401 Unauthorized (expired/invalid token) and 403 Forbidden
api.interceptors.response.use(
(response) => response,
(error) => {
@@ -30,6 +30,12 @@ api.interceptors.response.use(
localStorage.removeItem('access_token')
// Optionally redirect to login could be added here later
}
// P0 BUGFIX (2026-07-28): Log but do NOT retry 403 errors.
// Permission errors should be terminal — they should not cause
// retry loops in Pinia stores or component watchers.
if (error.response?.status === 403) {
console.warn(`[API] 403 Forbidden: ${error.config?.url || 'unknown endpoint'}`)
}
return Promise.reject(error)
}
)

View File

@@ -679,7 +679,8 @@ const FUEL_SUBCATEGORY_CODES = new Set(['OPERATION_FUEL', 'OPERATION_ELECTRIC'])
*/
const selectedSubCategoryCode = computed<string | null>(() => {
if (!form.subCategory) return null
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
const rawCats = (window as any).__allCostCategories
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
const found = allCats.find((c) => c.id === Number(form.subCategory))
return found?.code || null
})
@@ -791,13 +792,28 @@ const flattenCategories = (cats: any[]): any[] => {
}, [])
}
/**
* P1 BUGFIX (2026-07-28): Category defensive normalization.
*
* Backend may return { items: [...] }, { data: [...] }, or null instead of a flat array.
* This guard prevents TypeError: allCategories.reduce/filter/find is not a function.
*/
function normalizeCategoryData(data: any): any[] {
if (Array.isArray(data)) return data
if (data && Array.isArray(data.items)) return data.items
if (data && Array.isArray(data.data)) return data.data
console.warn('[CostEntryWizard] Unexpected category format, using empty array:', data)
return []
}
async function fetchCategories() {
try {
// Pass visibility=b2c so only user-facing categories are returned
const res = await api.get('/dictionaries/cost-categories', {
params: { visibility: 'b2c' },
})
const allCategories = flattenCategories(res.data) as CategoryOption[]
const normalized = normalizeCategoryData(res.data)
const allCategories = flattenCategories(normalized) as CategoryOption[]
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
window.__allCostCategories = allCategories
} catch (err) {
@@ -821,7 +837,8 @@ function onMainCategoryChange() {
if (!form.mainCategory) return
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
const rawCats = (window as any).__allCostCategories
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
subCategories.value = allCats.filter(
(c) => c.parent_id === Number(form.mainCategory)
)

View File

@@ -223,10 +223,24 @@ const vehicleLabel = computed(() => {
/**
* Fetch hierarchical categories from the dictionaries API.
*/
/**
* P1 BUGFIX (2026-07-28): Category defensive normalization.
*
* Backend may return { items: [...] } or null instead of a flat array.
* This defensive guard prevents TypeError: allCategories.filter is not a function.
*/
function normalizeCategories(data: any): CategoryOption[] {
if (Array.isArray(data)) return data as CategoryOption[]
if (data && Array.isArray(data.items)) return data.items as CategoryOption[]
if (data && Array.isArray(data.data)) return data.data as CategoryOption[]
console.warn('[ComplexExpenseModal] Unexpected category format, using empty array:', data)
return []
}
async function fetchCategories() {
try {
const res = await api.get('/dictionaries/cost-categories')
const allCategories = res.data as CategoryOption[]
const allCategories = normalizeCategories(res.data)
// Split into main (parent_id === null) and sub categories
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
@@ -255,7 +269,8 @@ function onMainCategoryChange() {
if (!form.mainCategory) return
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
const rawCats = (window as any).__allCostCategories
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
subCategories.value = allCats.filter(
(c) => c.parent_id === Number(form.mainCategory)
)
@@ -296,7 +311,8 @@ async function handleSubmit() {
if (props.isFeeMode) {
// Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID
costType = 'fee'
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
const rawCats = (window as any).__allCostCategories
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
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 {

View File

@@ -33,9 +33,18 @@
<!-- Action Buttons -->
<div class="flex-1 flex flex-col gap-2.5 justify-center">
<!-- No vehicle warning -->
<!-- Local loading skeleton -->
<div
v-if="!selectedActionVehicle"
v-if="vehicleStore.isLoading && vehicleStore.vehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400"
>
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-[#70BC84] mb-2" />
<p class="text-xs">{{ t('dashboard.loading') }}</p>
</div>
<!-- No vehicle warning (only show when data has loaded but there are none) -->
<div
v-else-if="!selectedActionVehicle && !vehicleStore.isLoading && vehicleStore.vehicles.length === 0"
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
>
<span> {{ t('dashboard.noVehicleSelected') }}</span>

View File

@@ -16,25 +16,36 @@
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
<div class="flex-1 overflow-y-auto space-y-2">
<VehicleCardCompact
v-for="vehicle in displayVehicles.slice(0, 3)"
:key="vehicle.id"
:vehicle="vehicle"
class="relative z-20"
@click.stop="navigateToFleet"
@plate-click.stop="openVehicleDetail(vehicle)"
/>
<!-- Empty state (only when BOTH real and mock are empty) -->
<!-- Local loading skeleton -->
<div
v-if="displayVehicles.length === 0"
v-if="vehicleStore.isLoading && vehicleStore.vehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400 relative z-20"
@click.stop="navigateToFleet"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-[#70BC84] mb-2" />
<p class="text-sm">{{ t('dashboard.loading') }}</p>
</div>
<template v-else>
<VehicleCardCompact
v-for="vehicle in displayVehicles.slice(0, 3)"
:key="vehicle.id"
:vehicle="vehicle"
class="relative z-20"
@click.stop="navigateToFleet"
@plate-click.stop="openVehicleDetail(vehicle)"
/>
<!-- Empty state (only when BOTH real and mock are empty) -->
<div
v-if="displayVehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400 relative z-20"
@click.stop="navigateToFleet"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
</div>
</template>
</div>
</div>
</div>

View File

@@ -368,10 +368,24 @@ function autoCalculate() {
// ── Category Resolution ──
const fuelCategoryId = ref<number>(1) // Default fallback
/**
* P1 BUGFIX (2026-07-28): Category defensive normalization.
*
* Backend may return { items: [...] }, { data: [...] }, or null instead of a flat array.
* This guard prevents TypeError: allCats.find is not a function.
*/
function normalizeCategories(data: any): Array<{ id: number; code?: string; name: string; parent_id: number | null }> {
if (Array.isArray(data)) return data
if (data && Array.isArray(data.items)) return data.items
if (data && Array.isArray(data.data)) return data.data
console.warn('[SimpleFuelModal] Unexpected category format, using empty array:', data)
return []
}
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 allCats = normalizeCategories(res.data)
const fuelCat = allCats.find((c) => c.code === 'FUEL' && c.parent_id === null)
if (fuelCat) {
fuelCategoryId.value = fuelCat.id

View File

@@ -27,7 +27,7 @@
<svg class="w-4 h-4 text-[#70BC84]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
{{ subscriptionPlan }}
{{ subscriptionDisplayName }}
</span>
<span
v-if="daysRemaining !== null"
@@ -112,6 +112,44 @@ const subscriptionPlan = computed(() => {
return authStore.user?.subscription_plan || null
})
/**
* P0 BUGFIX (2026-07-28): Resolve the human-readable subscription display name.
*
* Priority:
* 1. subscription_display_name from the backend (now populated by _resolve_subscription_data)
* 2. For corporate mode: try org-level subscription_display_name
* 3. Fallback: slug→human mapping for common plan names
* 4. Ultimate fallback: raw subscription_plan string
*/
const subscriptionDisplayName = computed(() => {
// Priority 1: user-level display_name from backend
const userDisplayName = authStore.user?.subscription_display_name
if (userDisplayName) return userDisplayName
// Priority 2: corporate mode — check org level
if (authStore.isCorporateMode) {
const activeOrg = authStore.myOrganizations.find(
(o) => o.organization_id === authStore.user?.active_organization_id
)
if (activeOrg?.subscription_display_name) return activeOrg.subscription_display_name
}
// Priority 3: slug→human mapping
const plan = subscriptionPlan.value
if (!plan || plan === 'FREE') return 'Ingyenes'
const slugMap: Record<string, string> = {
'private_free_v1': 'Privát Ingyenes',
'private_basic_v1': 'Privát Alap',
'private_pro_v1': 'Privát Pro',
'private_vip_v1': 'Privát VIP',
'corp_basic_v1': 'Céges Alap',
'corp_premium_v1': 'Céges Prémium',
'corp_premium_plus_v1': 'Céges Prémium Plus',
'corp_enterprise_v1': 'Céges Enterprise',
}
return slugMap[plan] || plan
})
/**
* Calculate days remaining until subscription expires.
* Uses valid_until from the organization or user data.
@@ -185,23 +223,20 @@ function formatDate(dateStr: string | null): string {
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
onMounted(async () => {
// Ensure organizations are loaded
if (authStore.myOrganizations.length === 0) {
try {
await authStore.fetchMyOrganizations()
} catch {
// Silently fail
}
}
// Ensure vehicles are loaded
if (vehicleStore.vehicles.length === 0) {
try {
await vehicleStore.fetchVehicles()
} catch {
// Silently fail
}
}
/**
* P0 BUGFIX (2026-07-28): Admin Loading Loop — removed redundant data fetches.
*
* ROOT CAUSE: SubscriptionStatusWidget.onMounted() called vehicleStore.fetchVehicles()
* when vehicles.length === 0. This set vehicleStore.isLoading = true, which caused
* DashboardView to show the spinner and destroy the grid (v-if="!isLoading").
* When the fetch completed, isLoading = false, the grid re-mounted, SubscriptionStatusWidget
* mounted fresh, checked vehicles.length === 0 again, re-fetched → INFINITE LOOP.
*
* FIX: The parent DashboardView already calls authStore.fetchMyOrganizations() and
* vehicleStore.fetchVehicles() in its own onMounted(). This widget should only
* set its local isLoading to false — it should NOT trigger its own data fetches.
*/
onMounted(() => {
isLoading.value = false
})
</script>

View File

@@ -187,14 +187,17 @@ const activeGarageName = computed(() => {
return activeOrg.display_name || activeOrg.name || `${t('header.companyPrefix')} #${activeOrg.organization_id}`
}
}
// If on dashboard and has an active individual org, show its name
// If on dashboard and has an active organization, show its name
// ONLY if it's a business/corporate org (not individual/private)
if (!isOnCompanyGarage.value && authStore.user?.active_organization_id) {
const activeOrg = authStore.myOrganizations.find(
(org) => org.organization_id === authStore.user?.active_organization_id
)
if (activeOrg) {
if (activeOrg && activeOrg.org_type !== 'individual') {
return activeOrg.display_name || activeOrg.name || t('header.garageSelector')
}
// Individual (private) orgs on dashboard: show "Saját Garázs" / "Private Garage"
return t('header.privateGarage')
}
// Default: show personal garage label
return t('header.garageSelector')

View File

@@ -662,11 +662,25 @@ function onFormFieldChange() {
}, 500)
}
/**
* P1 BUGFIX (2026-07-28): Category defensive normalization.
*
* Backend may return { items: [...] }, { data: [...] }, or null instead of a flat array.
* This guard prevents TypeError on .filter / .find / .reduce.
*/
function normalizeCategories(data: any): any[] {
if (Array.isArray(data)) return data
if (data && Array.isArray(data.items)) return data.items
if (data && Array.isArray(data.data)) return data.data
console.warn('[ProviderQuickAddModal] Unexpected category format, using empty array:', data)
return []
}
// ── Fetch Categories (legacy dropdown fallback) ──
async function fetchCategories() {
try {
const res = await api.get('providers/categories')
categories.value = res.data || []
categories.value = normalizeCategories(res.data)
} catch {
categories.value = [
{ id: 1, key: 'auto_szerelo', name_hu: 'Autószerelő', name_en: 'Car Mechanic', category: 'vehicle_service' },
@@ -949,19 +963,11 @@ watch(() => form.name, onFormFieldChange)
watch(() => form.city, onFormFieldChange)
watch(() => form.address_zip, onFormFieldChange)
// ── Load categories on mount ──
onMounted(() => {
fetchCategories()
loadCategoryTree()
})
// 🔍 P0 Debug: Universal Level 1 categories count verification
watchEffect(() => {
console.log(`[ProviderQuickAddModal] universalLevel1Categories count: ${universalLevel1Categories.value.length}`)
if (universalLevel1Categories.value.length > 0) {
console.log(`[ProviderQuickAddModal] Universal categories:`, universalLevel1Categories.value.map(c => c.name_hu || c.name_en))
}
})
// P0 BUGFIX (2026-07-28): Removed onMounted pre-fetch and debug watchEffect.
// loadCategoryTree() and fetchCategories() are already called in the
// watch(() => props.isOpen) handler (line 946-959), so pre-fetching on
// mount is redundant AND harmful — it triggers API calls even when the
// modal is closed, contributing to the infinite admin reactivity loop.
</script>
<style scoped>

View File

@@ -142,10 +142,35 @@
</li>
</ul>
</div>
<!-- Phase 4: Selected Add-ons Summary (if any) -->
<div
v-if="cartAddonTotal > 0"
class="rounded-xl border border-emerald-200 bg-emerald-50 p-3"
>
<p class="text-xs font-semibold text-emerald-700 uppercase tracking-wider mb-2">
🛒 {{ t('subscription.selectedAddons') }} ({{ selectedAddonCount }})
</p>
<div class="flex items-center justify-between text-sm">
<span class="text-emerald-800">{{ t('subscription.addonTotal') }}:</span>
<span class="font-bold text-emerald-700">{{ formatPrice(cartAddonTotal) }}/{{ t('subscription.month') }}</span>
</div>
</div>
</div>
<!-- FOOTER -->
<!-- FOOTER (with combined total if add-ons selected) -->
<div class="shrink-0 px-6 py-4 border-t border-slate-200 bg-slate-50">
<!-- Combined total display -->
<div
v-if="cartAddonTotal > 0 && !isCurrentPlan"
class="flex items-center justify-between text-sm mb-3 px-2"
>
<span class="text-slate-600 font-medium">{{ t('subscription.monthlyTotal') }}:</span>
<span class="text-lg font-bold text-slate-900">
{{ formatPrice(combinedMonthlyPrice) }}/{{ t('subscription.month') }}
</span>
</div>
<button
class="w-full py-3 rounded-xl font-bold text-white transition-colors text-sm"
:class="isCurrentPlan ? 'bg-slate-400 cursor-not-allowed' : 'bg-blue-800 hover:bg-blue-900'"
@@ -162,6 +187,20 @@
</template>
<script setup lang="ts">
/**
* Phase 4 CART LOGIC:
*
* This modal now accepts two new props:
* - cartAddonTotal: The sum of monthly prices of selected add-ons
* - selectedAddonCount: The number of selected add-ons
*
* The footer displays the combined monthly total
* (base plan monthly price + cartAddonTotal) when add-ons are selected.
*
* The combined price is only for display purposes — the actual purchase
* request sends the base plan's tier_id and includes addon_ids in metadata.
* The backend activates both the base plan and the selected add-ons.
*/
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -187,11 +226,17 @@ const props = withDefaults(
visible: boolean
plan: SubscriptionPlan | null
currentPlanName?: string | null
/** Phase 4: Total monthly price of selected add-ons */
cartAddonTotal?: number
/** Phase 4: Number of selected add-ons */
selectedAddonCount?: number
}>(),
{
visible: false,
plan: null,
currentPlanName: null,
cartAddonTotal: 0,
selectedAddonCount: 0,
}
)
@@ -216,6 +261,17 @@ const resolvedYearlyPrice = computed(() => {
return props.plan?.resolved_pricing?.yearly_price ?? props.plan?.rules?.pricing?.yearly_price ?? 0
})
/**
* Phase 4 CART LOGIC: Compute the combined monthly price (base plan + selected add-ons).
*
* This is the monthly price the user would pay if they purchase the base plan
* together with all selected add-ons. It is for display only — the actual
* billing is handled server-side.
*/
const combinedMonthlyPrice = computed(() => {
return resolvedMonthlyPrice.value + props.cartAddonTotal
})
/**
* Get resolved currency from plan's resolved_pricing or fallback to rules.pricing.
*/

View File

@@ -172,9 +172,31 @@ const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
// ── Computed: Plan display name ──
// P0 BUGFIX (2026-07-28): Backend now sends subscription_display_name via
// _resolve_subscription_data(). Use it with slug→human fallback matching
// SubscriptionStatusWidget logic.
const planDisplayName = computed(() => {
// Priority 1: Backend-supplied human-readable name from SubscriptionTier.rules
const displayName = authStore.user?.subscription_display_name
if (displayName) return displayName
// Priority 2: Slug-to-human mapping fallback
const plan = authStore.user?.subscription_plan
if (!plan || plan === 'FREE') return t('subscription.free')
const slugToHuman: Record<string, string> = {
'private_basic_v1': 'Privát Alap',
'private_pro_v1': 'Privát Pro',
'private_vip_v1': 'Privát VIP',
'corp_free_v1': 'Céges Ingyenes',
'corp_basic_v1': 'Céges Alap',
'corp_pro_v1': 'Céges Pro',
'corp_vip_v1': 'Céges VIP',
'addon_extra_vehicle_v1': 'Extra Jármű',
}
if (slugToHuman[plan]) return slugToHuman[plan]
// Priority 3: Raw plan name as ultimate fallback
return plan
})

View File

@@ -1,4 +1,8 @@
export default {
// P2 BUGFIX (2026-07-28): Added missing i18n keys for ModeSwitcher component
switchMode: 'Switch Mode',
consumer: 'Consumer',
corporate: 'Corporate',
// ── Common / Global ──
common: {
cancel: 'Cancel',
@@ -71,6 +75,7 @@ export default {
apiAccess: 'API access',
prioritySupport: 'Priority support',
adFree: 'Ad-free',
affiliateCommission: 'Affiliate commission',
},
// P0: Subscription Info Modal
subscriptionInfo: 'Subscription Info',
@@ -85,6 +90,26 @@ export default {
unlimited: 'unlimited',
timeRemaining: 'Time Remaining',
days: 'days',
renewNow: 'Renew Now',
upgradePlan: 'Upgrade Plan',
// P0 Flip Card
registeredAt: 'Registration Date',
startedAt: 'Subscription Start',
activeAddons: 'Active Add-ons',
noAddons: 'No active add-ons',
validUntil: 'Valid until',
clickToFlipBack: 'Flip back',
details: 'Details',
// ── Phase 4: Add-on Cart & Back Button ──
backToFinance: '← Back to Finance',
addonsSection: 'Available Add-ons',
addonsDesc: 'Enhance your plan with extra features',
addToCart: 'Add to cart',
removeFromCart: 'Remove',
addonTotal: 'Add-on total',
monthlyTotal: 'Monthly total',
selectedAddons: 'Selected add-ons',
noAddonsAvailable: 'No add-ons available',
},
landing: {
openGarage: 'Open Garage',

View File

@@ -1,4 +1,8 @@
export default {
// P2 BUGFIX (2026-07-28): Added missing i18n keys for ModeSwitcher component
switchMode: 'Mód váltás',
consumer: 'Felhasználó',
corporate: 'Vállalat',
// ── Common / Globális ──
common: {
cancel: 'Mégse',
@@ -71,6 +75,7 @@ export default {
apiAccess: 'API hozzáférés',
prioritySupport: 'Prioritásos támogatás',
adFree: 'Reklámmentes',
affiliateCommission: 'Partner jutalék',
},
// P0: Subscription Info Modal
subscriptionInfo: 'Előfizetés Információ',
@@ -85,6 +90,26 @@ export default {
unlimited: 'korlátlan',
timeRemaining: 'Hátralévő idő',
days: 'nap',
renewNow: 'Újítás most',
upgradePlan: 'Csomag váltása',
// P0 Flip Card
registeredAt: 'Regisztráció dátuma',
startedAt: 'Előfizetés kezdete',
activeAddons: 'Aktív kiegészítők',
noAddons: 'Nincsenek aktív kiegészítők',
validUntil: 'Érvényes',
clickToFlipBack: 'Visszafordítás',
details: 'Részletek',
// ── Phase 4: Add-on Cart & Back Button ──
backToFinance: '← Vissza a Pénzügyekhez',
addonsSection: 'Elérhető kiegészítők',
addonsDesc: 'Bővítsd a csomagod extra funkciókkal',
addToCart: 'Kosárba',
removeFromCart: 'Eltávolítás',
addonTotal: 'Kiegészítők összesen',
monthlyTotal: 'Havi összesen',
selectedAddons: 'Kiválasztott kiegészítők',
noAddonsAvailable: 'Nincsenek elérhető kiegészítők',
},
landing: {
openGarage: 'Garázs Nyitása',

View File

@@ -64,6 +64,22 @@ export interface UserProfile {
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
max_vehicles?: number
max_garages?: number
/** P0 Subscription Card Upgrade: Human-readable plan name (e.g., "Privát Pro") */
subscription_display_name?: string | null
/** P0 Subscription Card Upgrade: FK to system.subscription_tiers */
subscription_tier_id?: number | null
/** P0 Flip Card: User registration date (ISO 8601) */
user_registration_date?: string | null
/** P0 Flip Card: Current subscription start date (ISO 8601) */
subscription_valid_from?: string | null
/** P0 Flip Card: List of active add-on subscriptions */
active_addons?: Array<{
tier_id: number
display_name: string
valid_from: string | null
valid_until: string | null
is_active: boolean
}> | null
scope_level: string
scope_id: string | null
ui_mode: string
@@ -88,6 +104,23 @@ export const useAuthStore = defineStore('auth', () => {
const myOrganizations = ref<OrganizationItem[]>([])
const isInitialized = ref(false)
/**
* Reactive version counter — incremented on every successful organization switch.
* Components and stores watch this to trigger data re-fetches for context-aware data.
*/
const contextVersion = ref(0)
// ── Deduplication guards ──────────────────────────────────────────
// P0 BUGFIX (2026-07-28): Prevent concurrent fetchUser() calls from
// App.vue.onMounted() and router.beforeEach. Without this guard,
// user.value gets set twice, triggering a reactivity cascade that
// causes the dashboard infinite mount/unmount loop for admin users.
let _userFetchInProgress = false
// P0 BUGFIX (2026-07-28): Single-execution guard for init() to prevent
// race conditions between App.vue.onMounted() and router.beforeEach.
let _initInProgress = false
// ────────────────────────────── Getters ────────────────────────────
const isAuthenticated = computed(() => !!token.value && !!user.value)
const isKycComplete = computed(() => {
@@ -248,7 +281,11 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('pending_verification_email')
// If KYC is not complete, redirect to KYC page
if (!isKycComplete.value) {
// BUT skip for admin/staff users — they use the admin panel, not the B2C app
const staffRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']
const role = user.value?.role
const isStaffUser = role && staffRoles.includes(role.toUpperCase())
if (!isKycComplete.value && !isStaffUser) {
router.push('/complete-kyc')
}
} catch (err: any) {
@@ -263,8 +300,15 @@ export const useAuthStore = defineStore('auth', () => {
* Fetch the current user profile from /auth/me.
*/
async function fetchUser() {
// P0 BUGFIX (2026-07-28): Deduplication guard — prevents concurrent
// fetchUser() calls from App.vue.onMounted() and router.beforeEach.
// Without this, user.value is set twice, triggering a reactivity cascade
// that re-evaluates all computed properties in dashboard widgets.
if (_userFetchInProgress) return
if (!token.value) return
_userFetchInProgress = true
try {
const res = await api.get('/auth/me')
user.value = res.data as UserProfile
@@ -274,6 +318,8 @@ export const useAuthStore = defineStore('auth', () => {
logout()
}
throw err
} finally {
_userFetchInProgress = false
}
}
@@ -401,6 +447,13 @@ export const useAuthStore = defineStore('auth', () => {
* Initialize the store on app load — if a token exists, fetch the user.
*/
async function init() {
// P0 BUGFIX (2026-07-28): Single-execution guard. Prevents concurrent
// init() calls from App.vue.onMounted() and router.beforeEach().
// Without this, fetchUser() + fetchMyOrganizations() fire twice,
// causing reactivity cascade in dashboard widget computed properties.
if (_initInProgress || isInitialized.value) return
_initInProgress = true
if (token.value) {
try {
await fetchUser()
@@ -412,6 +465,7 @@ export const useAuthStore = defineStore('auth', () => {
}
// Mark initialization as complete so the router guard can await it
isInitialized.value = true
_initInProgress = false
}
/**
@@ -689,7 +743,18 @@ export const useAuthStore = defineStore('auth', () => {
* Fetch the current user's organizations from GET /api/v1/organizations/my.
* Stores them in myOrganizations state.
*/
// P0 BUGFIX (2026-07-28): Deduplication guard to prevent multiple simultaneous
// calls from DashboardView.onMounted(), HeaderCompanySwitcher.onMounted(),
// and init(). Without this, rapid concurrent fetches cause race conditions
// in Pinia state that trigger excessive reactivity updates.
let _orgFetchInProgress = false
async function fetchMyOrganizations() {
if (_orgFetchInProgress) {
// Skip duplicate: a fetch is already in flight
return myOrganizations.value
}
_orgFetchInProgress = true
try {
const res = await api.get('/organizations/my')
myOrganizations.value = res.data as OrganizationItem[]
@@ -698,6 +763,8 @@ export const useAuthStore = defineStore('auth', () => {
console.error('Failed to fetch organizations:', err)
myOrganizations.value = []
return []
} finally {
_orgFetchInProgress = false
}
}
@@ -723,7 +790,19 @@ export const useAuthStore = defineStore('auth', () => {
* (active_organization_id is set and matches a business org).
*/
const isCorporateMode = computed(() => {
return !!user.value?.active_organization_id
if (!user.value?.active_organization_id) return false
// Find the active org and check its type to distinguish
// individual (private) orgs from business (corporate) orgs.
const activeOrg = myOrganizations.value.find(
(org) => org.organization_id === user.value?.active_organization_id
)
if (!activeOrg) {
// Fallback: if org data isn't loaded yet, assume corporate
// to avoid leaking corporate packages to private users
return true
}
// Individual orgs are NOT corporate mode
return activeOrg.org_type !== 'individual'
})
/**
@@ -752,6 +831,12 @@ export const useAuthStore = defineStore('auth', () => {
// Update the user state with the fresh profile
user.value = data.user
// P0 BUGFIX (2026-07-28): Context-switching data leak fix.
// Increment the version counter so DashboardView and other components
// watching authStore.contextVersion can trigger re-fetches of
// context-scoped data (vehicles, wallet balance, costs, etc.).
contextVersion.value++
} catch (err: any) {
error.value =
err.response?.data?.detail ||
@@ -771,6 +856,7 @@ export const useAuthStore = defineStore('auth', () => {
error,
myOrganizations,
isInitialized,
contextVersion,
// Getters
isAuthenticated,

View File

@@ -138,7 +138,27 @@ export const useVehicleStore = defineStore('vehicle', () => {
* Fetch all vehicles/assets for the current user or their organization.
* GET /assets/vehicles
*/
// P0 BUGFIX (2026-07-28): Deduplication guard to prevent multiple simultaneous
// calls from DashboardView.onMounted() and child components.
let _vehicleFetchInProgress = false
/**
* P0 BUGFIX (2026-07-28): Context-switching data leak fix.
* Clears the local vehicle cache and resets the deduplication guard.
* Called by DashboardView before re-fetching after organization switch.
*/
function clearCache() {
_vehicleFetchInProgress = false
vehicles.value = []
error.value = null
}
async function fetchVehicles() {
if (_vehicleFetchInProgress) {
// Skip duplicate: a fetch is already in flight
return vehicles.value
}
_vehicleFetchInProgress = true
isLoading.value = true
error.value = null
@@ -154,6 +174,7 @@ export const useVehicleStore = defineStore('vehicle', () => {
console.error('[VehicleStore] fetchVehicles error:', err)
} finally {
isLoading.value = false
_vehicleFetchInProgress = false
}
}
@@ -433,6 +454,7 @@ export const useVehicleStore = defineStore('vehicle', () => {
sortedVehicles,
// Actions
clearCache,
fetchVehicles,
fetchVehicleById,
createVehicle,

View File

@@ -20,6 +20,8 @@ export interface OrganizationItem {
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
max_vehicles?: number
max_garages?: number
/** P0: Human-readable plan display name (e.g. "Privát Pro") */
subscription_display_name?: string | null
/** org_type is now reliably returned by GET /api/v1/organizations/my */
org_type: string
/**

View File

@@ -38,17 +38,12 @@
<!-- Bottom-aligned 5-card container -->
<div class="flex flex-col justify-end min-h-[85vh] pb-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
<!-- Loading State -->
<!-- 5-Card Grid (P0: ALWAYS mounted, no global v-if guard)
CRITICAL: Do NOT add v-if="!vehicleStore.isLoading" here.
Each child widget has its own local loading state and handles
empty data gracefully. A global v-if would destroy the grid on
any loading toggle, causing infinite mount/unmount loops. -->
<div
v-if="vehicleStore.isLoading"
class="flex items-center justify-center py-20"
>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]" />
</div>
<!-- 5-Card Grid -->
<div
v-if="!vehicleStore.isLoading"
class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
style="perspective: 1200px;"
>
@@ -362,9 +357,21 @@ function handleSetPrimaryFromDetail(vehicle: VehicleData) {
vehicleStore.setPrimaryVehicle(vehicle.id)
}
onMounted(() => {
vehicleStore.fetchVehicles()
authStore.fetchMyOrganizations()
onMounted(async () => {
// P0 BUGFIX (2026-07-28): Sequential fetches to prevent race conditions
// between isLoading toggle and myOrganizations state updates that cause
// the infinite admin reactivity loop.
await vehicleStore.fetchVehicles()
// P0 BUGFIX (2026-07-28): Only fetch organizations if not already populated.
// authStore.init() already loads myOrganizations during app startup and
// router navigation guard. Unconditional re-fetching here creates a second
// API call that updates the ref, triggering a reactivity cascade in all
// dashboard widgets' computed properties (isCorporateMode, subscriptionPlan,
// validUntil, maxVehicles, etc.) — causing the admin infinite loop.
if (authStore.myOrganizations.length === 0) {
await authStore.fetchMyOrganizations()
}
// ── Handle query param card navigation ──────────────────────────
const cardParam = route.query.card as string | undefined
@@ -382,6 +389,35 @@ watch(() => route.query.card, (newCard) => {
router.replace({ query: {} })
}
})
// ── P0 BUGFIX (2026-07-28): Context-switching data leak fix ─────
// Watch authStore.contextVersion to trigger re-fetch of all context-scoped
// dashboard data when the user switches organizations (private garage ↔ company).
// Without this watcher, DashboardView.onMounted() only runs once, so
// vehicle + wallet data remains stale after a context switch.
watch(
() => authStore.contextVersion,
async () => {
// Skip initial mount (onMounted already fetches)
if (authStore.contextVersion === 0) return
console.log('[DashboardView] Context switch detected, re-fetching dashboard data...')
// Clear the vehicle cache and reset dedup guard so the fetch actually runs
vehicleStore.clearCache()
// Re-fetch vehicles for the new organization context
vehicleStore.fetchVehicles()
// Re-fetch wallet balance for the new organization context
const { useFinanceStore } = await import('../stores/finance')
const financeStore = useFinanceStore()
financeStore.fetchWalletBalance()
// Re-fetch organizations so the header switcher stays in sync
authStore.fetchMyOrganizations()
}
)
</script>
<style scoped>

View File

@@ -116,38 +116,157 @@
</div>
<!--
Card 2: 📦 Csomagok (Packages)
Shows authStore.subscription_plan with active plan name.
Card 2: 📦 Csomagok (Packages) 3D FLIP CARD
Front: display_name, expiry, vehicle bar, CTA button.
Back: registration date, valid_from, active add-ons list.
Click to flip CTA button uses @click.stop to not trigger flip.
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
class="flip-perspective bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="toggleFlip"
>
<!-- Invisible overlay for click capture -->
<div
class="absolute inset-0 z-10 cursor-pointer"
@click="goTo('/finance/packages')"
/>
<!-- Header bar -->
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.title') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Current plan card -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
{{ t('subscription.active') }}
</p>
<p class="text-2xl font-extrabold text-sky-600">
{{ subscriptionPlanName }}
</p>
class="flip-inner"
:class="{ 'is-flipped': isFlipped }"
>
<!-- FRONT FACE -->
<div class="flip-face flex flex-col">
<!-- Header bar -->
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.title') }}</span>
<span
:class="isExpired ? 'bg-red-500/30 text-red-200' : 'bg-emerald-500/30 text-emerald-200'"
class="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
>
{{ isExpired ? t('subscription.expired') : t('subscription.active') }}
</span>
</div>
<!-- Quick info row -->
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
<p class="text-xs text-slate-500">{{ t('subscription.subtitle') }}</p>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Plan name -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
{{ t('subscription.active') }}
</p>
<p class="text-2xl font-extrabold text-sky-600">
{{ subscriptionDisplayName }}
</p>
</div>
<!-- Expiry date -->
<div v-if="formattedExpiry" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
<p class="text-xs text-slate-500">
{{ t('subscription.expiresAt') }}:
<span
:class="isExpired ? 'text-red-600 font-bold' : 'text-slate-700 font-semibold'"
>{{ formattedExpiry }}</span>
</p>
</div>
<!-- Vehicle usage -->
<div v-if="maxVehicles > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs text-slate-500">
<span>🚗 {{ t('subscription.vehicles') }}</span>
<span class="font-semibold text-slate-700">{{ vehicleCount }} / {{ maxVehicles }}</span>
</div>
<div class="w-full h-1.5 rounded-full bg-slate-200 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="vehiclePercent >= 90 ? 'bg-red-500' : vehiclePercent >= 75 ? 'bg-amber-400' : 'bg-sky-500'"
:style="{ width: `${Math.min(vehiclePercent, 100)}%` }"
/>
</div>
</div>
</div>
<!-- CTA Button (click.stop prevents flip) -->
<div class="shrink-0 pt-2">
<router-link
:to="ctaRoute"
@click.stop
class="block w-full text-center px-4 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 shadow-md hover:shadow-lg active:scale-[0.98]"
:class="isExpired
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white hover:from-amber-400 hover:to-orange-500'
: 'bg-gradient-to-r from-sky-600 to-indigo-600 text-white hover:from-sky-500 hover:to-indigo-500'"
>
{{ ctaLabel }}
</router-link>
</div>
</div>
</div>
<!-- BACK FACE -->
<div class="flip-face flip-back flex flex-col bg-white rounded-2xl">
<!-- Header bar (same gradient, mirrored) -->
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.details') }}</span>
<button
@click.stop="toggleFlip"
class="ml-auto text-xs text-white/70 hover:text-white transition-colors"
>
{{ t('subscription.clickToFlipBack') }}
</button>
</div>
<!-- Back content: scrollable if overflow -->
<div class="flex-1 overflow-y-auto p-4 space-y-3 text-slate-800">
<!-- Registration date -->
<div v-if="userRegistrationDate" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
<p class="text-xs text-slate-500">{{ t('subscription.registeredAt') }}:</p>
<p class="text-sm font-semibold text-slate-700">{{ userRegistrationDate }}</p>
</div>
<!-- Subscription start date -->
<div v-if="subscriptionStartDate" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
<p class="text-xs text-slate-500">{{ t('subscription.startedAt') }}:</p>
<p class="text-sm font-semibold text-slate-700">{{ subscriptionStartDate }}</p>
</div>
<!-- Exact expiry date (prominent) -->
<div v-if="formattedExpiry" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
<p class="text-xs text-slate-500">{{ t('subscription.expiresAt') }}:</p>
<p
class="text-sm font-semibold"
:class="isExpired ? 'text-red-600' : 'text-slate-700'"
>{{ formattedExpiry }}</p>
</div>
<!-- Active Add-ons -->
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
<p class="text-xs text-slate-500 font-semibold mb-2">{{ t('subscription.activeAddons') }}:</p>
<div v-if="activeAddons.length === 0" class="text-xs text-slate-400 italic">
{{ t('subscription.noAddons') }}
</div>
<div v-else class="space-y-2">
<div
v-for="addon in activeAddons"
:key="addon.tier_id"
class="flex items-center justify-between text-xs"
>
<div class="flex flex-col">
<span class="font-medium text-slate-700">{{ addon.display_name }}</span>
<span class="text-slate-400">
{{ t('subscription.validUntil') }}: {{ formatAddonDate(addon.valid_until) }}
</span>
</div>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="isAddonExpired(addon)
? 'bg-red-100 text-red-600'
: 'bg-emerald-100 text-emerald-700'"
>
{{ isAddonExpired(addon) ? t('subscription.expired') : t('subscription.active') }}
</span>
</div>
</div>
</div>
<!-- Tip to flip back -->
<p class="text-center text-[10px] text-slate-400 pt-1">
{{ t('subscription.clickToFlipBack') }}
</p>
</div>
</div>
</div>
@@ -222,16 +341,18 @@
* Wallet balance fetched on mount via financeStore.
* No flip mechanic — cards use direct @click routing.
*/
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useFinanceStore } from '../stores/finance'
import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle'
const router = useRouter()
const { t } = useI18n()
const financeStore = useFinanceStore()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
/**
* goTo — navigate to a finance sub-route using the router.
@@ -246,9 +367,62 @@ function goTo(path: string) {
const walletLoading = computed(() => financeStore.isLoading)
const walletError = computed(() => financeStore.error)
// ── Card 2: Subscription plan name (authStore pattern) ────────────────
const subscriptionPlanName = computed(() => {
return authStore.user?.subscription_plan || '—'
// ── Card 2: Subscription data (upgraded) ──────────────────────────
const subscriptionDisplayName = computed(() => {
// Priority: 1) display_name from backend, 2) humanized slug, 3) fallback
const displayName = authStore.user?.subscription_display_name
if (displayName) return displayName
const plan = authStore.user?.subscription_plan
if (!plan || plan === 'FREE') return t('subscription.free')
// Simple slug→human mapping as fallback
const slugMap: Record<string, string> = {
'private_pro_v1': 'Privát Pro',
'corp_premium_v1': 'Céges Prémium',
'corp_premium_plus_v1': 'Céges Prémium Plus',
}
return slugMap[plan] || plan
})
const subscriptionExpiresAt = computed(() => {
return authStore.user?.subscription_expires_at ?? null
})
const formattedExpiry = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return null
const d = new Date(raw)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}.${m}.${day}`
})
const isExpired = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return false
return new Date(raw) < new Date()
})
const vehicleCount = computed(() => vehicleStore.vehicles?.length || 0)
const maxVehicles = computed(() => authStore.user?.max_vehicles ?? 0)
const vehiclePercent = computed(() => {
if (!maxVehicles.value || maxVehicles.value <= 0) return 0
return Math.min(100, (vehicleCount.value / maxVehicles.value) * 100)
})
// CTA routing depends on mode
const ctaRoute = computed(() => {
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
return `/organization/${authStore.user.active_organization_id}/subscription`
}
return '/dashboard/subscription'
})
const ctaLabel = computed(() => {
if (isExpired.value) return t('subscription.renewNow') || 'Újítás most'
return t('subscription.managePlan')
})
// ── Helper: format credits ───────────────────────────────────────────
@@ -259,8 +433,90 @@ function formatCredits(value: number): string {
return value.toFixed(2)
}
// ── Fetch wallet balance on mount ────────────────────────────────────
// ── Flip card state ─────────────────────────────────────────
const isFlipped = ref(false)
function toggleFlip() {
isFlipped.value = !isFlipped.value
}
// ── Back face: user registration date ───────────────────────
const userRegistrationDate = computed(() => {
const raw = authStore.user?.user_registration_date
if (!raw) return null
const d = new Date(raw)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return y + '.' + m + '.' + day
})
// ── Back face: subscription start date ──────────────────────
const subscriptionStartDate = computed(() => {
const raw = authStore.user?.subscription_valid_from
if (!raw) return null
const d = new Date(raw)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return y + '.' + m + '.' + day
})
// ── Back face: active add-ons ───────────────────────────────
const activeAddons = computed(() => {
return authStore.user?.active_addons || []
})
// ── Back face: add-on helpers ───────────────────────────────
function formatAddonDate(raw: string | null): string {
if (!raw) return '—'
const d = new Date(raw)
if (isNaN(d.getTime())) return raw
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return y + '.' + m + '.' + day
}
function isAddonExpired(addon: { valid_until: string | null }): boolean {
if (!addon.valid_until) return false
return new Date(addon.valid_until) < new Date()
}
// ── Fetch data on mount ─────────────────────────────────────────────
onMounted(() => {
financeStore.fetchWalletBalance()
// Ensure vehicles are loaded for usage display
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
})
</script>
<style scoped>
/* ── 3D Flip Card ──────────────────────────────────────────── */
.flip-perspective {
perspective: 1000px;
}
.flip-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
transform-style: preserve-3d;
}
.flip-inner.is-flipped {
transform: rotateY(180deg);
}
.flip-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.flip-back {
transform: rotateY(180deg);
}
</style>

View File

@@ -6,10 +6,24 @@
</div>
<div class="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Header -->
<div class="text-center mb-10">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('subscription.title') }}</h1>
<p class="text-white/60">{{ t('subscription.subtitle') }}</p>
<!-- HEADER: Title + Back Button -->
<div class="flex items-center justify-between mb-8">
<!-- Phase 4 UX FIX: Back Button -->
<!-- Navigates to /finance/packages if coming from finance, otherwise /dashboard -->
<button
@click="goBack"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('subscription.backToFinance') }}
</button>
<div class="text-right">
<h1 class="text-3xl font-bold text-white">{{ t('subscription.title') }}</h1>
<p class="text-white/60 text-sm mt-1">{{ t('subscription.subtitle') }}</p>
</div>
</div>
<!-- Loading State -->
@@ -34,121 +48,257 @@
</button>
</div>
<!-- MINIMALIST PLAN CARDS -->
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<BaseCard
v-for="plan in filteredPlans"
:key="plan.id"
:class="{
'ring-2 ring-emerald-500': plan.name === currentPlanName,
'hover:-translate-y-2 hover:shadow-2xl': true,
}"
header-color="bg-[#306081]"
height="auto"
body-padding="lg"
:custom-class="`border-2 ${plan.name === currentPlanName ? 'border-emerald-500' : 'border-slate-800'} transition-all duration-300`"
<!-- PLANS + ADD-ONS -->
<div v-else class="space-y-10">
<!-- BASE PLANS SECTION -->
<div
v-if="basePlans.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<!-- HEADER: Plan Name + Badge -->
<template #header>
<div class="flex items-center justify-between w-full">
<span class="text-white font-bold text-sm tracking-wide">
{{ plan.rules?.display_name || plan.name }}
</span>
<span
v-if="plan.rules?.marketing?.badge"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full"
:style="{
backgroundColor: plan.rules.marketing.highlight_color || '#70BC84',
color: '#fff',
}"
>
{{ plan.rules.marketing.badge }}
</span>
</div>
</template>
<!-- BODY: Minimalist Content -->
<div class="flex flex-col h-full">
<!-- Subtitle -->
<p
v-if="plan.rules?.marketing?.subtitle"
class="text-xs text-slate-500 mb-4 leading-relaxed"
>
{{ plan.rules.marketing.subtitle }}
</p>
<!-- Price (from resolved_pricing) -->
<div class="text-center mb-5">
<div class="text-3xl font-bold text-slate-900">
{{ formatPrice(resolvedMonthlyPrice(plan)) }}
<span class="text-sm font-normal text-slate-500">/{{ t('subscription.month') }}</span>
</div>
<!-- Yearly price hint -->
<div v-if="resolvedYearlyPrice(plan) > 0" class="text-xs text-slate-400 mt-1">
{{ formatPrice(resolvedYearlyPrice(plan)) }}/{{ t('subscription.year') }}
<span v-if="yearlySavingsPercent(plan) > 0" class="text-emerald-600 font-semibold ml-1">
({{ t('subscription.savePercent', { percent: yearlySavingsPercent(plan) }) }})
<BaseCard
v-for="plan in basePlans"
:key="plan.id"
:class="{
'ring-2 ring-emerald-500': plan.name === currentPlanName,
'hover:-translate-y-2 hover:shadow-2xl': true,
}"
header-color="bg-[#306081]"
height="auto"
body-padding="lg"
:custom-class="`border-2 ${plan.name === currentPlanName ? 'border-emerald-500' : 'border-slate-800'} transition-all duration-300`"
>
<!-- HEADER: Plan Name + Badge -->
<template #header>
<div class="flex items-center justify-between w-full">
<span class="text-white font-bold text-sm tracking-wide">
{{ plan.rules?.display_name || plan.name }}
</span>
<span
v-if="plan.rules?.marketing?.badge"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full"
:style="{
backgroundColor: plan.rules.marketing.highlight_color || '#70BC84',
color: '#fff',
}"
>
{{ plan.rules.marketing.badge }}
</span>
</div>
</div>
</template>
<!-- 3 KEY STATS (visual highlight) -->
<div class="grid grid-cols-3 gap-2 mb-5">
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.max_vehicles ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.maxVehicles') }}
</div>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.max_garages ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.maxGarages') }}
</div>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.monthly_free_credits ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.freeCredits') }}
</div>
</div>
</div>
<!-- BODY: Minimalist Content -->
<div class="flex flex-col h-full">
<!-- Subtitle -->
<p
v-if="plan.rules?.marketing?.subtitle"
class="text-xs text-slate-500 mb-4 leading-relaxed"
>
{{ plan.rules.marketing.subtitle }}
</p>
<!-- Spacer -->
<div class="flex-1" />
<!-- Action: Current Plan Badge or Details Button -->
<div v-if="plan.name === currentPlanName" class="mt-auto">
<div class="text-center text-sm text-emerald-600 font-semibold mb-3 bg-emerald-50 py-2 rounded-lg border border-emerald-200">
{{ t('subscription.currentPlan') }}
<!-- Price (from resolved_pricing) -->
<div class="text-center mb-5">
<div class="text-3xl font-bold text-slate-900">
{{ formatPrice(resolvedMonthlyPrice(plan)) }}
<span class="text-sm font-normal text-slate-500">/{{ t('subscription.month') }}</span>
</div>
<!-- Yearly price hint -->
<div v-if="resolvedYearlyPrice(plan) > 0" class="text-xs text-slate-400 mt-1">
{{ formatPrice(resolvedYearlyPrice(plan)) }}/{{ t('subscription.year') }}
<span v-if="yearlySavingsPercent(plan) > 0" class="text-emerald-600 font-semibold ml-1">
({{ t('subscription.savePercent', { percent: yearlySavingsPercent(plan) }) }})
</span>
</div>
</div>
<!-- 3 KEY STATS (visual highlight) -->
<div class="grid grid-cols-3 gap-2 mb-5">
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.max_vehicles ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.maxVehicles') }}
</div>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.max_garages ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.maxGarages') }}
</div>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.monthly_free_credits ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.freeCredits') }}
</div>
</div>
</div>
<!-- Spacer -->
<div class="flex-1" />
<!-- Action: Current Plan Badge or Details Button -->
<div v-if="plan.name === currentPlanName" class="mt-auto">
<div class="text-center text-sm text-emerald-600 font-semibold mb-3 bg-emerald-50 py-2 rounded-lg border border-emerald-200">
{{ t('subscription.currentPlan') }}
</div>
</div>
<button
v-else
@click="openDetails(plan)"
class="w-full py-2.5 rounded-xl bg-blue-800 hover:bg-blue-900 text-white font-semibold transition-colors text-sm mt-auto"
>
{{ t('subscription.detailsAndPurchase') }}
</button>
</div>
</BaseCard>
</div>
<!-- EMPTY BASE PLANS -->
<div v-else class="text-center py-10">
<p class="text-white/50">{{ t('subscription.noPlan') }}</p>
</div>
<!--
Phase 4: ADD-ON SECTION (Issue #432)
PURPOSE: Display available add-on packages beneath base plans.
Each add-on card shows its monthly price and a toggle button.
The cart state tracks which add-ons the user has selected.
CART LOGIC:
- selectedAddonIds: Set<number> tracks selected add-on IDs
- toggleAddon(id): adds/removes the add-on from the cart
- cartAddonTotal: sum of monthly prices of selected add-ons
- When the user opens a PlanDetailsModal for a base plan,
the modal footer shows the combined total
(base plan price + selected add-on prices).
-->
<div v-if="addonPlans.length > 0" class="space-y-4">
<!-- Section Header -->
<div class="text-center">
<h2 class="text-2xl font-bold text-white">
🔧 {{ t('subscription.addonsSection') }}
</h2>
<p class="text-white/60 text-sm mt-1">{{ t('subscription.addonsDesc') }}</p>
</div>
<!-- Cart summary bar (visible when add-ons are selected) -->
<div
v-if="cartAddonTotal > 0"
class="rounded-xl bg-white/10 backdrop-blur-sm border border-white/20 p-3 flex items-center justify-between text-white"
>
<span class="text-sm">
🛒 {{ t('subscription.selectedAddons') }} ({{ selectedAddonIds.size }}):
<strong>{{ formatPrice(cartAddonTotal) }}/{{ t('subscription.month') }}</strong>
</span>
<button
v-else
@click="openDetails(plan)"
class="w-full py-2.5 rounded-xl bg-blue-800 hover:bg-blue-900 text-white font-semibold transition-colors text-sm mt-auto"
@click="clearCart"
class="text-xs text-white/60 hover:text-white transition-colors underline"
>
{{ t('subscription.detailsAndPurchase') }}
{{ t('common.delete') }}
</button>
</div>
</BaseCard>
<!-- Add-on cards grid solid white cards with green gradient header -->
<!-- Phase 4 BUGFIX (2026-07-28): Replaced dark glass classes (bg-emerald-900/20,
border-slate-700, text-white/xx) with solid white card pattern matching
the base plan cards. Added green emerald gradient header for visual
distinction. See docs/UI_STANDARDS.md Section 6. -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="addon in addonPlans"
:key="addon.id"
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col transition-all duration-300"
:class="isAddonSelected(addon.id)
? 'border-emerald-500 ring-1 ring-emerald-500/50 hover:-translate-y-2 hover:shadow-2xl'
: 'border-slate-200 hover:border-slate-400 hover:-translate-y-2 hover:shadow-2xl'"
>
<!-- Green gradient header bar (distinguishes add-ons from base plan dark blue headers) -->
<div class="h-12 bg-gradient-to-r from-emerald-600 to-emerald-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">
{{ addon.rules?.display_name || addon.name }}
</span>
<span class="ml-auto text-white/80 text-xs font-bold">
{{ formatPrice(resolvedMonthlyPrice(addon)) }}/{{ t('subscription.month') }}
</span>
</div>
<!-- Body -->
<div class="p-4 flex-1 flex flex-col text-slate-800">
<!-- Subtitle -->
<p v-if="addon.rules?.marketing?.subtitle" class="text-xs text-slate-500 mb-3 leading-relaxed">
{{ addon.rules.marketing.subtitle }}
</p>
<!-- Allowances mini stat boxes (matching base plan stat style) -->
<div class="grid grid-cols-3 gap-2 mb-3">
<div v-if="addon.rules?.allowances?.max_vehicles" class="bg-slate-50 rounded-lg p-2 text-center">
<div class="text-base font-bold text-slate-900">
+{{ addon.rules.allowances.max_vehicles }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
🚗 {{ t('subscription.vehicles') }}
</div>
</div>
<div v-if="addon.rules?.allowances?.max_garages" class="bg-slate-50 rounded-lg p-2 text-center">
<div class="text-base font-bold text-slate-900">
+{{ addon.rules.allowances.max_garages }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
🏠 {{ t('subscription.maxGarages') }}
</div>
</div>
<div v-if="addon.rules?.allowances?.monthly_free_credits" class="bg-slate-50 rounded-lg p-2 text-center">
<div class="text-base font-bold text-slate-900">
+{{ addon.rules.allowances.monthly_free_credits }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
💰 {{ t('subscription.freeCredits') }}
</div>
</div>
<!-- Empty placeholder when some stat boxes are missing (keeps grid aligned) -->
<div v-if="!addon.rules?.allowances?.max_vehicles && !addon.rules?.allowances?.max_garages && !addon.rules?.allowances?.monthly_free_credits" class="col-span-3 text-xs text-slate-400 italic text-center py-2">
{{ t('subscription.noAddonsAvailable') }}
</div>
</div>
<div class="flex-1" />
<!-- Toggle button solid bg matching standard card buttons -->
<button
@click="toggleAddon(addon.id)"
class="w-full py-2 rounded-xl text-sm font-semibold transition-all duration-200"
:class="isAddonSelected(addon.id)
? 'bg-emerald-600 hover:bg-emerald-700 text-white'
: 'bg-slate-100 hover:bg-emerald-50 text-slate-700 hover:text-emerald-700 border border-slate-200 hover:border-emerald-300'"
>
<template v-if="isAddonSelected(addon.id)">
{{ t('subscription.removeFromCart') }}
</template>
<template v-else>
+ {{ t('subscription.addToCart') }}
</template>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- PLAN DETAILS MODAL -->
<!-- PLAN DETAILS MODAL (with add-on cart total in footer) -->
<PlanDetailsModal
:visible="showModal"
:plan="selectedPlan"
:current-plan-name="currentPlanName"
:cart-addon-total="cartAddonTotal"
:selected-addon-count="selectedAddonIds.size"
@close="closeModal"
@order="handleOrder"
/>
@@ -157,12 +307,15 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import api from '../api/axios'
import BaseCard from '../components/ui/BaseCard.vue'
import PlanDetailsModal from '../components/subscription/PlanDetailsModal.vue'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const authStore = useAuthStore()
@@ -185,22 +338,140 @@ const isLoading = ref(true)
const error = ref<string | null>(null)
const allPlans = ref<SubscriptionPlan[]>([])
// ── Phase 4: Add-on Cart State ─────────────────────────────────────────────────
/**
* Cart state for add-on selection.
* Uses a Set<number> to track selected add-on IDs (O(1) lookup).
*
* CART LOGIC:
* - toggleAddon(id): Adds or removes the add-on from the cart.
* - clearCart(): Empties the cart.
* - cartAddonTotal: Computed sum of monthly prices of all selected add-ons.
* This is passed to PlanDetailsModal so the modal footer can show
* "Base plan price + Add-on total = Combined monthly total".
*/
const selectedAddonIds = ref<Set<number>>(new Set())
/**
* Toggle an add-on's selection state.
* If already selected, remove it; otherwise add it.
*/
function toggleAddon(addonId: number) {
const newSet = new Set(selectedAddonIds.value)
if (newSet.has(addonId)) {
newSet.delete(addonId)
} else {
newSet.add(addonId)
}
selectedAddonIds.value = newSet
}
/**
* Check if a given add-on ID is currently in the cart.
*/
function isAddonSelected(addonId: number): boolean {
return selectedAddonIds.value.has(addonId)
}
/**
* Clear all selected add-ons from the cart.
*/
function clearCart() {
selectedAddonIds.value = new Set()
}
/**
* Phase 4 CART LOGIC: Compute the total monthly price of selected add-ons.
*
* Iterates over addonPlans and sums the monthly prices of those whose
* IDs are in selectedAddonIds. Used by the modal footer to display
* the combined monthly total.
*/
const cartAddonTotal = computed(() => {
let total = 0
for (const addon of addonPlans.value) {
if (selectedAddonIds.value.has(addon.id)) {
total += resolvedMonthlyPrice(addon)
}
}
return total
})
// ── Phase 4: Separate base plans from add-ons ─────────────────────────────────
/**
* Base plans: subscription tiers where rules.type is NOT 'addon'.
* These are the main subscription packages (private_*, corp_*, etc.).
*/
const basePlans = computed(() => {
return filteredPlans.value.filter((plan) => {
const planType = plan.rules?.type
return planType !== 'addon'
})
})
/**
* Add-on plans: subscription tiers where rules.type === 'addon'.
* These are supplementary packages (extra vehicles, extra garages, etc.).
*/
const addonPlans = computed(() => {
return filteredPlans.value.filter((plan) => {
const planType = plan.rules?.type
return planType === 'addon'
})
})
// Modal state
const showModal = ref(false)
const selectedPlan = ref<SubscriptionPlan | null>(null)
/**
* Phase 4 UX FIX: Navigate back to the appropriate previous view.
*
* Navigation logic:
* - If the referrer contains 'finance', go to /finance/packages
* - If the route has an org_id param, go to /organization/:org_id
* - Otherwise go to /dashboard/subscription
*/
function goBack() {
// Check if we're in an organization context
const orgId = route.params.org_id
if (orgId) {
router.push(`/organization/${orgId}`)
return
}
// Check referrer
const referrer = document.referrer || ''
if (referrer.includes('/finance')) {
router.push('/finance/packages')
return
}
router.push('/dashboard')
}
/**
* Filter plans based on the user's current mode:
* - Individual (no active org): show only `private_` packages
* - Corporate (active org): show only `corp_` packages
* Add-ons (type === 'addon') are NOT filtered by mode — they show for everyone.
*/
const filteredPlans = computed(() => {
const isCorporate = authStore.isCorporateMode
return allPlans.value.filter((plan) => {
const planType = plan.rules?.type
// Add-ons are always visible regardless of mode
if (planType === 'addon') return true
// For base plans, apply mode-based filtering
if (planType === 'corporate' || planType === 'business') return isCorporate
if (planType === 'private' || planType === 'consumer') return !isCorporate
// Fallback: name prefix matching
if (isCorporate) {
return plan.name.startsWith('corp_')
return plan.name.startsWith('corp_') || plan.name.startsWith('business_')
}
return plan.name.startsWith('private_')
return plan.name.startsWith('private_') || plan.name.startsWith('consumer_')
})
})
@@ -285,16 +556,58 @@ function closeModal() {
}
/**
* Handle order from modal (simulation).
* Phase 4 CART LOGIC: Handle order with add-on IDs in metadata.
*
* When the user purchases a base plan, the selected add-on IDs are
* passed in the `metadata.addon_ids` field so the backend can also
* activate the corresponding add-on subscriptions.
*/
function handleOrder(plan: SubscriptionPlan, isYearly: boolean) {
console.log('[SubscriptionPlansView] Order simulation:', {
plan: plan.name,
isYearly,
price: isYearly ? resolvedYearlyPrice(plan) : resolvedMonthlyPrice(plan),
currency: resolvedCurrency(plan),
})
// TODO: Integrate with actual checkout flow
async function handleOrder(plan: SubscriptionPlan, isYearly: boolean) {
const price = isYearly ? resolvedYearlyPrice(plan) : resolvedMonthlyPrice(plan)
const currency = resolvedCurrency(plan)
// Determine if this should be an org-level subscription
const orgId = authStore.isCorporateMode && authStore.user?.active_organization_id
? authStore.user.active_organization_id
: null
try {
// Phase 4: Include selected add-on IDs in the metadata
const metadata: Record<string, any> = {}
if (selectedAddonIds.value.size > 0) {
metadata.addon_ids = Array.from(selectedAddonIds.value)
}
const res = await api.post('/financial-manager/purchase-package', {
tier_id: plan.id,
org_id: orgId,
region_code: authStore.user?.region_code || 'GLOBAL',
currency: currency,
metadata: metadata,
})
const data = res.data
if (data.success) {
console.log('[SubscriptionPlansView] Purchase successful:', data)
// Refresh user profile to get updated subscription state
await authStore.fetchUser()
if (authStore.user?.active_organization_id) {
await authStore.fetchMyOrganizations()
}
// Clear cart after successful purchase
clearCart()
// Show success message
alert(t('subscription.purchaseSuccess') || 'Subscription activated successfully!')
} else {
console.error('[SubscriptionPlansView] Purchase failed:', data.error)
alert(data.error || t('subscription.purchaseError') || 'Purchase failed.')
}
} catch (err: any) {
console.error('[SubscriptionPlansView] Purchase error:', err)
const detail = err.response?.data?.detail || err.message || 'Purchase failed'
alert(detail)
}
closeModal()
}