Files
service-finder/frontend_admin/components/garages/GarageSubscriptionTab.vue
2026-06-29 14:11:15 +00:00

704 lines
32 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="space-y-6">
<!-- Current Package Card - Densified with Utilization Stats -->
<div class="bg-slate-800/50 border border-slate-700 rounded-xl overflow-hidden">
<div class="px-6 py-4 border-b border-slate-700 flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-500/10 text-amber-400">
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<h3 class="text-base font-semibold text-white">{{ $t('garages.details.subscription_info') }}</h3>
</div>
<div class="px-6 py-5">
<div v-if="garage.subscription" class="grid grid-cols-2 md:grid-cols-4 gap-4">
<!-- Tier Name -->
<div class="bg-slate-900/30 rounded-lg p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ $t('garages.details.tier_name') }}</p>
<span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium" :class="tierBadgeClass(garage.subscription.tier_name)">
{{ garage.subscription.tier_name }}
</span>
</div>
<!-- Tier Level -->
<div class="bg-slate-900/30 rounded-lg p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ $t('garages.details.tier_level') }}</p>
<p class="text-sm font-semibold text-white">{{ $t('garages.details.level_n', { n: garage.subscription.tier_level }) }}</p>
</div>
<!-- Expiration -->
<div class="bg-slate-900/30 rounded-lg p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ $t('garages.details.expires_at') }}</p>
<p v-if="garage.subscription.expires_at" class="text-sm font-semibold text-white">{{ formatDate(garage.subscription.expires_at) }}</p>
<p v-else class="text-sm text-slate-400 italic">{{ $t('garages.indefinite') }}</p>
</div>
<!-- Asset Limit -->
<div class="bg-slate-900/30 rounded-lg p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ $t('garages.details.asset_limit') }}</p>
<p class="text-sm font-semibold text-white">{{ garage.subscription.asset_limit }} {{ $t('garages.details.vehicles') }}</p>
</div>
</div>
<!-- Utilization Stats Row -->
<div v-if="garage.subscription" class="mt-4 grid grid-cols-2 gap-4">
<!-- Vehicles Utilization -->
<div class="bg-slate-900/30 rounded-lg p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ $t('garages.details.vehicles_used') }}</p>
<p class="text-sm font-semibold text-white">
{{ fleetVehicles.length }} / {{ garage.subscription?.asset_limit ?? '∞' }}
</p>
<div class="mt-2 w-full h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="vehicleQuotaPercent >= 90 ? 'bg-red-500' : vehicleQuotaPercent >= 70 ? 'bg-amber-500' : 'bg-blue-500'"
:style="{ width: Math.min(100, vehicleQuotaPercent) + '%' }"
></div>
</div>
</div>
<!-- Branches Utilization -->
<div class="bg-slate-900/30 rounded-lg p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ $t('garages.details.branches_used') }}</p>
<p class="text-sm font-semibold text-white">
{{ garage.branches?.length || 0 }} / {{ garage.subscription?.branch_limit ?? '∞' }}
</p>
<div class="mt-2 w-full h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="branchQuotaPercent >= 90 ? 'bg-red-500' : branchQuotaPercent >= 70 ? 'bg-amber-500' : 'bg-emerald-500'"
:style="{ width: Math.min(100, branchQuotaPercent) + '%' }"
></div>
</div>
</div>
<!-- Employees Count (P0 ADD-ON: Now with user_limit bar) -->
<div class="bg-slate-900/30 rounded-lg p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ $t('garages.details.employees_used') }}</p>
<p class="text-sm font-semibold text-white">
{{ garage.member_count || 0 }} / {{ (garage.subscription?.user_limit && garage.subscription.user_limit > 0) ? garage.subscription.user_limit : '∞' }}
</p>
<div v-if="garage.subscription?.user_limit && garage.subscription.user_limit > 0" class="mt-2 w-full h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="employeeQuotaPercent >= 90 ? 'bg-red-500' : employeeQuotaPercent >= 70 ? 'bg-amber-500' : 'bg-purple-500'"
:style="{ width: Math.min(100, employeeQuotaPercent) + '%' }"
></div>
</div>
</div>
<!-- Status Badge -->
<div class="bg-slate-900/30 rounded-lg p-3 flex flex-col justify-center">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ $t('garages.details.status') }}</p>
<span
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium self-start"
:class="statusBadgeClass(garage.status)"
>
<span class="w-1.5 h-1.5 rounded-full" :class="statusDotClass(garage.status)"></span>
{{ statusLabel(garage.status) }}
</span>
</div>
</div>
<!-- P0 UI/UX: Active Add-ons section inside the Subscription Status card -->
<div v-if="garage.subscription?.addons?.length" class="mt-4 border-t border-slate-700/50 pt-4">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">{{ $t('garages.active_addons') }}</p>
<div class="overflow-hidden border border-slate-700/50 rounded-lg">
<table class="w-full text-sm">
<thead>
<tr class="bg-slate-900/40">
<th class="px-3 py-2 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.addon_type') }}</th>
<th class="px-3 py-2 text-center text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.addon_quantity') }}</th>
<th class="px-3 py-2 text-right text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.addon_expires') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="addon in garage.subscription.addons" :key="addon.type" class="bg-slate-900/20 hover:bg-slate-900/40 transition">
<td class="px-3 py-2.5 text-slate-300">{{ addonLabel(addon.type) }}</td>
<td class="px-3 py-2.5 text-center">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-indigo-500/20 text-indigo-300 text-xs font-bold">{{ addon.quantity }}</span>
</td>
<td class="px-3 py-2.5 text-right text-slate-500">
<span v-if="addon.expires_at || addon.valid_until">{{ formatDate(addon.expires_at || addon.valid_until) }}</span>
<span v-else class="italic">{{ $t('garages.indefinite') }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="garage.subscription" class="mt-4 border-t border-slate-700/50 pt-4">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ $t('garages.active_addons') }}</p>
<div class="bg-slate-900/20 border border-slate-700/30 rounded-lg px-4 py-3 text-center">
<p class="text-xs text-slate-500 italic">{{ $t('garages.no_active_addons') }}</p>
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-sm text-slate-400">{{ $t('garages.details.no_subscription') }}</p>
</div>
</div>
</div>
<!-- Change Package Section -->
<div class="bg-slate-800/50 border border-slate-700 rounded-xl overflow-hidden">
<div class="px-6 py-4 border-b border-slate-700 flex items-center gap-3">
<div class="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<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 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>
</div>
<h3 class="text-base font-semibold text-white">{{ $t('garages.change_package') }}</h3>
</div>
<div class="px-6 py-5 space-y-5">
<!-- Tier Selection -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">{{ $t('garages.select_package') }}</label>
<select
v-model="selectedTierId"
class="w-full px-3 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
<option value="" disabled>{{ $t('garages.select_package_placeholder') }}</option>
<option
v-for="tier in availableTiers"
:key="tier.id"
:value="tier.id"
>
{{ tier.name }} (Level {{ tier.tier_level }})
</option>
</select>
</div>
<!-- Custom Expiration Date -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
{{ $t('garages.custom_expiration') }}
<span class="text-xs text-slate-500 ml-1">({{ $t('garages.optional') }})</span>
</label>
<input
v-model="customExpiresAt"
type="date"
class="w-full px-3 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
/>
<p class="text-xs text-slate-500 mt-1.5">{{ $t('garages.custom_expiration_hint') }}</p>
</div>
<!-- P0 ITEMIZED ADD-ON: Add-on editor with individual date pickers -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-3">
{{ $t('garages.addons_title') }}
<span class="text-xs text-slate-500 ml-1">({{ $t('garages.optional') }})</span>
</label>
<!-- Add-on editor: number inputs with +/- buttons and individual date pickers -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<!-- Extra Vehicles -->
<div class="bg-slate-900/30 border border-slate-700/50 rounded-lg p-3">
<label class="block text-xs text-slate-400 mb-1.5">{{ $t('garages.addon_vehicles') }}</label>
<div class="flex items-center gap-2">
<button
@click="extraVehicles = Math.max(0, extraVehicles - 1)"
class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center justify-center transition text-sm font-bold"
></button>
<input
v-model.number="extraVehicles"
type="number"
min="0"
max="999"
class="w-full px-2 py-1.5 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white text-center focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
@click="extraVehicles = Math.min(999, extraVehicles + 1)"
class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center justify-center transition text-sm font-bold"
>+</button>
</div>
<label class="block text-xs text-slate-500 mt-2 mb-1">{{ $t('garages.addon_vehicles_expiration') }}</label>
<input
v-model="addonDates.vehicles"
type="date"
class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
/>
</div>
<!-- Extra Branches -->
<div class="bg-slate-900/30 border border-slate-700/50 rounded-lg p-3">
<label class="block text-xs text-slate-400 mb-1.5">{{ $t('garages.addon_branches') }}</label>
<div class="flex items-center gap-2">
<button
@click="extraBranches = Math.max(0, extraBranches - 1)"
class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center justify-center transition text-sm font-bold"
></button>
<input
v-model.number="extraBranches"
type="number"
min="0"
max="999"
class="w-full px-2 py-1.5 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white text-center focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
@click="extraBranches = Math.min(999, extraBranches + 1)"
class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center justify-center transition text-sm font-bold"
>+</button>
</div>
<label class="block text-xs text-slate-500 mt-2 mb-1">{{ $t('garages.addon_branches_expiration') }}</label>
<input
v-model="addonDates.branches"
type="date"
class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
/>
</div>
<!-- Extra Users -->
<div class="bg-slate-900/30 border border-slate-700/50 rounded-lg p-3">
<label class="block text-xs text-slate-400 mb-1.5">{{ $t('garages.addon_users') }}</label>
<div class="flex items-center gap-2">
<button
@click="extraUsers = Math.max(0, extraUsers - 1)"
class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center justify-center transition text-sm font-bold"
></button>
<input
v-model.number="extraUsers"
type="number"
min="0"
max="999"
class="w-full px-2 py-1.5 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white text-center focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
@click="extraUsers = Math.min(999, extraUsers + 1)"
class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center justify-center transition text-sm font-bold"
>+</button>
</div>
<label class="block text-xs text-slate-500 mt-2 mb-1">{{ $t('garages.addon_users_expiration') }}</label>
<input
v-model="addonDates.users"
type="date"
class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
/>
</div>
</div>
<p class="text-xs text-slate-500 mt-3">{{ $t('garages.addon_expiration_hint') }}</p>
</div>
<!-- Save Error -->
<div
v-if="subscriptionSaveError"
class="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3"
>
<p class="text-sm text-red-400">{{ subscriptionSaveError }}</p>
</div>
<!-- Save Button -->
<div class="flex items-center justify-end gap-3 pt-2">
<button
@click="updateSubscription"
:disabled="subscriptionSaving || (!selectedTierId && !addonsChanged)"
class="px-5 py-2 text-sm font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
:class="subscriptionSaving
? 'bg-indigo-600/50 text-indigo-200 cursor-wait'
: 'bg-indigo-600 text-white hover:bg-indigo-500'"
>
<span v-if="subscriptionSaving" class="flex items-center gap-2">
<svg 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>
{{ $t('garages.saving') }}
</span>
<span v-else>{{ $t('garages.save') }}</span>
</button>
</div>
</div>
</div>
<!-- Subscription History Section -->
<div class="bg-slate-800/50 border border-slate-700 rounded-xl overflow-hidden">
<div class="px-6 py-4 border-b border-slate-700 flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-500/10 text-cyan-400">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-base font-semibold text-white">{{ $t('garages.details.subscription_history') }}</h3>
</div>
<div class="px-6 py-5">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.details.history_date') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.details.history_package') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.details.history_action') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.details.history_status') }}</th>
</tr>
</thead>
<tbody>
<template v-if="garage?.subscription_history?.length">
<tr v-for="hist in garage.subscription_history" :key="hist.id" class="border-b border-slate-700/50 last:border-b-0 hover:bg-slate-700/30 transition">
<td class="px-4 py-3 text-sm text-slate-300">
{{ formatDate(hist.timestamp) }}
</td>
<td class="px-4 py-3 text-sm text-slate-300">
{{ hist.tier_name || '—' }}
</td>
<td class="px-4 py-3 text-sm text-slate-300 max-w-xs">
<!-- P0 AUDIT DETAILS: Show parsed data diff instead of generic changes_summary -->
<div class="space-y-1">
<span class="text-xs text-slate-400 block">{{ hist.action || '—' }}</span>
<div v-if="formatChanges(hist)" class="text-xs text-slate-300 space-y-0.5">
<div v-for="(line, li) in formatChanges(hist)" :key="li" class="flex items-start gap-1">
<span class="text-cyan-400 shrink-0"></span>
<span>{{ line }}</span>
</div>
</div>
<span v-else class="text-xs text-slate-500 italic">Nincs részletes adat</span>
</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-col gap-1">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-400"
>
Sikeres
</span>
<!-- P0 AUDIT DETAILS: Show admin_user_id -->
<span v-if="hist.admin_user_id" class="text-[10px] text-slate-500 text-center">
Admin #{{ hist.admin_user_id }}
</span>
</div>
</td>
</tr>
</template>
<template v-else>
<tr>
<td colspan="4" class="px-4 py-12 text-center">
<div class="flex flex-col items-center gap-3">
<svg class="w-10 h-10 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm text-slate-400">{{ $t('garages.details.no_subscription_history') }}</p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useAuthStore } from '~/stores/auth'
const props = defineProps<{
garage: any
fleetVehicles: any[]
}>()
const emit = defineEmits<{
refresh: []
}>()
const auth = useAuthStore()
// ── Subscription tab state ──
const availableTiers = ref<any[]>([])
// ── Fetch tiers on mount so the dropdown is populated ──
onMounted(() => {
fetchTiers()
})
const selectedTierId = ref<number | ''>('')
const customExpiresAt = ref('')
const subscriptionSaving = ref(false)
const subscriptionSaveError = ref<string | null>(null)
// ── P0 CRITICAL FIX: Initialize add-on refs from existing subscription data ──
// Previously these were hardcoded to 0, which caused existing add-ons to be
// deleted on save (the frontend sent 0 for all add-on types, overwriting them).
const extraVehicles = ref(0)
const extraBranches = ref(0)
const extraUsers = ref(0)
const addonDates = reactive({
vehicles: '',
branches: '',
users: '',
})
// ── P0 CRITICAL FIX: Sync add-on refs when garage subscription data loads ──
// This ensures the form inputs reflect the ACTUAL stored values, not zero.
function syncAddonFromSubscription() {
const sub = props.garage?.subscription
if (!sub) return
const allowances = sub.extra_allowances || {}
extraVehicles.value = allowances.extra_vehicles ?? 0
extraBranches.value = allowances.extra_branches ?? 0
extraUsers.value = allowances.extra_users ?? 0
// Also sync add-on dates from the addons array if available
if (sub.addons?.length) {
for (const addon of sub.addons) {
if (addon.type === 'extra_vehicles' && addon.expires_at) {
addonDates.vehicles = addon.expires_at.slice(0, 10)
} else if (addon.type === 'extra_branches' && addon.expires_at) {
addonDates.branches = addon.expires_at.slice(0, 10)
} else if (addon.type === 'extra_users' && addon.expires_at) {
addonDates.users = addon.expires_at.slice(0, 10)
}
}
}
}
// Watch for garage subscription changes and sync add-on values
watch(() => props.garage?.subscription, (newSub) => {
if (newSub) {
syncAddonFromSubscription()
}
}, { immediate: true })
// ── Computed ──
const vehicleQuotaLimit = computed(() => {
if (!props.garage?.subscription?.asset_limit) return 5
return props.garage.subscription.asset_limit
})
const vehicleQuotaPercent = computed(() => {
const limit = vehicleQuotaLimit.value
if (limit <= 0) return 0
return Math.min(100, Math.round((props.fleetVehicles.length / limit) * 100))
})
const branchQuotaPercent = computed(() => {
const branches = props.garage?.branches?.length || 0
const limit = props.garage?.subscription?.branch_limit
if (!limit || limit <= 0) return 0
return Math.min(100, Math.round((branches / limit) * 100))
})
const employeeQuotaPercent = computed(() => {
const count = props.garage?.member_count || 0
const limit = props.garage?.subscription?.user_limit
if (!limit || limit <= 0) return 0
return Math.min(100, Math.round((count / limit) * 100))
})
const addonsChanged = computed(() => {
if (!props.garage?.subscription) return false
const sub = props.garage.subscription
const baselineVehicles = sub.extra_allowances?.extra_vehicles ?? 0
const baselineBranches = sub.extra_allowances?.extra_branches ?? 0
const baselineUsers = sub.extra_allowances?.extra_users ?? 0
return (
extraVehicles.value !== baselineVehicles ||
extraBranches.value !== baselineBranches ||
extraUsers.value !== baselineUsers
)
})
// ── P0 AUDIT DETAILS: Parse old_data/new_data into human-readable diff lines ──
function formatChanges(hist: any): string[] | null {
if (!hist) return null
const oldData = hist.old_data || {}
const newData = hist.new_data || {}
const lines: string[] = []
// SUBSCRIPTION_TIER_CHANGE: Show tier_id change
if (hist.action === 'SUBSCRIPTION_TIER_CHANGE') {
const oldTier = oldData.tier_id
const newTier = newData.tier_id
const newTierName = newData.tier_name
if (oldTier !== undefined || newTier !== undefined) {
lines.push(`Csomag: #${oldTier ?? '?'}${newTierName ?? '#' + newTier}`)
}
// Show addon changes within tier change
const oldAddons = oldData.addons || {}
const newAddons = newData.addons || {}
for (const key of ['extra_vehicles', 'extra_branches', 'extra_users']) {
const oldVal = oldAddons[key] ?? 0
const newVal = newAddons[key] ?? 0
if (oldVal !== newVal) {
const label = key === 'extra_vehicles' ? 'Extra Jármű'
: key === 'extra_branches' ? 'Extra Telephely'
: 'Extra Dolgozó'
lines.push(`${label}: ${oldVal}${newVal}`)
}
}
return lines.length > 0 ? lines : null
}
// SUBSCRIPTION_ADDON_OVERRIDE: Show individual add-on changes
if (hist.action === 'SUBSCRIPTION_ADDON_OVERRIDE') {
const oldAddons = oldData.addons || {}
const newAddons = newData.addons || {}
let hasChanges = false
for (const key of ['extra_vehicles', 'extra_branches', 'extra_users']) {
const oldVal = oldAddons[key] ?? 0
const newVal = newAddons[key] ?? 0
if (oldVal !== newVal) {
hasChanges = true
const label = key === 'extra_vehicles' ? 'Extra Jármű'
: key === 'extra_branches' ? 'Extra Telephely'
: 'Extra Dolgozó'
lines.push(`${label}: ${oldVal}${newVal}`)
}
}
return hasChanges ? lines : null
}
// Generic fallback: show any differing keys between old_data and new_data
const allKeys = new Set([...Object.keys(oldData), ...Object.keys(newData)])
for (const key of allKeys) {
if (key === 'addons') continue // already handled above
const oldVal = JSON.stringify(oldData[key] ?? null)
const newVal = JSON.stringify(newData[key] ?? null)
if (oldVal !== newVal) {
lines.push(`${key}: ${oldVal}${newVal}`)
}
}
return lines.length > 0 ? lines : null
}
// ── Helpers ──
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '-'
try {
return new Intl.DateTimeFormat('hu-HU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(dateStr))
} catch {
return dateStr
}
}
function addonLabel(type: string): string {
const map: Record<string, string> = {
extra_vehicles: 'Extra Jármű',
extra_branches: 'Extra Telephely',
extra_users: 'Extra Dolgozó',
}
return map[type] || type
}
function tierBadgeClass(tier: string | null | undefined): string {
const map: Record<string, string> = {
basic: 'bg-gray-100 text-gray-800',
premium: 'bg-yellow-100 text-yellow-800',
enterprise: 'bg-purple-100 text-purple-800',
trial: 'bg-blue-100 text-blue-800',
}
return map[tier?.toLowerCase()] || 'bg-gray-100 text-gray-800'
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
active: 'Active',
inactive: 'Inactive',
suspended: 'Suspended',
pending: 'Pending',
}
return map[status?.toLowerCase()] || status
}
function statusBadgeClass(status: string): string {
const map: Record<string, string> = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
suspended: 'bg-red-100 text-red-800',
pending: 'bg-yellow-100 text-yellow-800',
}
return map[status?.toLowerCase()] || 'bg-gray-100 text-gray-800'
}
function statusDotClass(status: string): string {
const map: Record<string, string> = {
active: 'bg-green-400',
inactive: 'bg-gray-400',
suspended: 'bg-red-400',
pending: 'bg-yellow-400',
}
return map[status?.toLowerCase()] || 'bg-gray-400'
}
// ── Subscription Tab Functions ──
async function fetchTiers() {
try {
const tokenCookie = useCookie('access_token')
const token = (tokenCookie.value || auth.token || '').trim()
console.log('[GarageSubscriptionTab] Fetching tiers...')
const res = await fetch('/api/v1/admin/packages?include_hidden=true&limit=100', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
if (!res.ok) {
const errBody = await res.text().catch(() => '')
console.error(`[GarageSubscriptionTab] Fetch tiers failed: ${res.status} ${res.statusText}`, errBody)
throw new Error(`Failed to fetch tiers: ${res.status} ${errBody}`)
}
const data = await res.json()
console.log('[GarageSubscriptionTab] Fetched tiers response:', JSON.stringify(data))
availableTiers.value = data.tiers || []
console.log('[GarageSubscriptionTab] availableTiers set:', availableTiers.value.length, 'items')
} catch (e: any) {
console.error('[GarageSubscriptionTab] Failed to fetch tiers:', e)
subscriptionSaveError.value = `Hiba a csomagok betöltésekor: ${e.message}`
}
}
async function updateSubscription() {
if (!selectedTierId.value && !addonsChanged.value) return
subscriptionSaving.value = true
subscriptionSaveError.value = null
try {
const tokenCookie = useCookie('access_token')
const token = (tokenCookie.value || auth.token || '').trim()
const orgId = props.garage?.id
const payload: Record<string, unknown> = {}
if (selectedTierId.value) {
payload.tier_id = selectedTierId.value
}
if (customExpiresAt.value) {
payload.expires_at = new Date(customExpiresAt.value).toISOString()
}
payload.extra_vehicles = extraVehicles.value
payload.extra_branches = extraBranches.value
payload.extra_users = extraUsers.value
// P0 ITEMIZED ADD-ON: Send individual expiration dates per add-on type
payload.extra_vehicles_expires_at = addonDates.vehicles || null
payload.extra_branches_expires_at = addonDates.branches || null
payload.extra_users_expires_at = addonDates.users || null
// ── P0 DEBUG: Log the exact payload being sent to the backend ──
console.log('[GarageSubscriptionTab] PATCH Payload:', JSON.stringify(payload, null, 2))
const res = await fetch(`/api/v1/admin/organizations/${orgId}/subscription`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!res.ok) {
const errData = await res.json().catch(() => ({}))
throw new Error(errData.detail || `Update subscription failed: ${res.status}`)
}
// Reset form state
selectedTierId.value = ''
emit('refresh')
} catch (e: any) {
subscriptionSaveError.value = e.message || 'Failed to update subscription'
} finally {
subscriptionSaving.value = false
}
}
// Expose fetchTiers so the parent can call it on tab activation
defineExpose({ fetchTiers })
</script>