admin_users_personels

This commit is contained in:
Roo
2026-06-29 14:11:15 +00:00
parent 383e5511b6
commit df4a0f07ff
237 changed files with 17849 additions and 2934 deletions

View File

@@ -0,0 +1,366 @@
<template>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-semibold text-white">{{ $t('users.trend_title') }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ $t('users.trend_subtitle') }}</p>
</div>
<!-- Period selector -->
<select
v-model="selectedPeriod"
class="px-2.5 py-1.5 text-xs bg-slate-700 border border-slate-600 rounded-lg text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
@change="updateChart"
>
<option value="7">{{ $t('users.trend_7d') }}</option>
<option value="14">{{ $t('users.trend_14d') }}</option>
<option value="30">{{ $t('users.trend_30d') }}</option>
<option value="90">{{ $t('users.trend_90d') }}</option>
</select>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<svg class="w-6 h-6 text-indigo-400 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>
</div>
<!-- Empty State -->
<div v-else-if="filteredData.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
<svg class="w-10 h-10 text-slate-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-slate-500 text-xs">{{ $t('users.trend_no_data') }}</p>
</div>
<!-- Chart Area -->
<div v-else class="relative">
<!-- SVG Bar Chart -->
<svg
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
class="w-full h-auto overflow-visible"
preserveAspectRatio="xMidYMid meet"
>
<!-- Grid lines -->
<line
v-for="(y, yi) in gridLines"
:key="'grid-' + yi"
:x1="padding.left"
:y1="y"
:x2="svgWidth - padding.right"
:y2="y"
stroke="currentColor"
class="text-slate-700/50"
stroke-width="1"
stroke-dasharray="4,4"
/>
<!-- Y-axis labels -->
<text
v-for="(label, li) in yLabels"
:key="'ylabel-' + li"
:x="padding.left - 8"
:y="label.y + 4"
text-anchor="end"
class="fill-slate-500"
font-size="10"
>
{{ label.text }}
</text>
<!-- Bars -->
<g
v-for="(item, idx) in visibleBars"
:key="'bar-' + idx"
class="chart-bar-group"
@mouseenter="hoveredBar = idx"
@mouseleave="hoveredBar = null"
>
<rect
:x="item.x"
:y="item.y"
:width="barWidth"
:height="item.height"
:rx="3"
:ry="3"
:fill="item.color"
:opacity="hoveredBar === idx ? 0.9 : 0.7"
class="transition-all duration-200 cursor-pointer"
/>
<!-- Tooltip -->
<g v-if="hoveredBar === idx">
<rect
:x="item.tooltipX"
:y="item.tooltipY - 28"
:width="item.tooltipWidth"
height="22"
rx="4"
fill="#1e293b"
stroke="#475569"
stroke-width="1"
/>
<text
:x="item.tooltipX + item.tooltipWidth / 2"
:y="item.tooltipY - 13"
text-anchor="middle"
fill="#e2e8f0"
font-size="11"
font-weight="600"
>
{{ item.tooltipText }}
</text>
</g>
</g>
<!-- X-axis labels (show every Nth label to avoid crowding) -->
<text
v-for="(item, xi) in xLabels"
:key="'xlabel-' + xi"
:x="item.x + barWidth / 2"
:y="svgHeight - padding.bottom + 16"
text-anchor="middle"
class="fill-slate-500"
font-size="9"
>
{{ item.label }}
</text>
</svg>
<!-- Summary Stats Below Chart -->
<div class="flex items-center justify-between mt-4 pt-3 border-t border-slate-700/50">
<div class="flex items-center gap-4 text-xs">
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-sm bg-indigo-500/70"></span>
<span class="text-slate-400">{{ $t('users.trend_daily') }}</span>
</div>
<div>
<span class="text-slate-400">{{ $t('users.trend_avg') }}:</span>
<span class="text-white font-semibold ml-1">{{ averageCount }}</span>
</div>
<div>
<span class="text-slate-400">{{ $t('users.trend_total') }}:</span>
<span class="text-white font-semibold ml-1">{{ totalCount }}</span>
</div>
</div>
<div v-if="trendDirection !== 'stable'" class="flex items-center gap-1 text-xs">
<svg
v-if="trendDirection === 'up'"
class="w-3.5 h-3.5 text-emerald-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<svg
v-else
class="w-3.5 h-3.5 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6" />
</svg>
<span :class="trendDirection === 'up' ? 'text-emerald-400' : 'text-red-400'" class="font-medium">
{{ trendPercent }}%
</span>
<span class="text-slate-500">{{ $t('users.trend_vs_prev') }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
interface TrendDataPoint {
date: string
count: number
}
const props = defineProps<{
data: TrendDataPoint[]
loading?: boolean
}>()
// ── State ──────────────────────────────────────────────────────────
const selectedPeriod = ref('30')
const hoveredBar = ref<number | null>(null)
// ── Chart Dimensions ────────────────────────────────────────────────
const svgWidth = 800
const svgHeight = 220
const padding = { top: 10, right: 16, bottom: 30, left: 36 }
// ── Computed ───────────────────────────────────────────────────────
const filteredData = computed(() => {
const days = parseInt(selectedPeriod.value, 10)
if (!props.data || props.data.length === 0) return []
// Sort by date ascending
const sorted = [...props.data].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
)
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - days)
return sorted.filter((d) => new Date(d.date) >= cutoff)
})
const maxCount = computed(() => {
if (filteredData.value.length === 0) return 1
return Math.max(...filteredData.value.map((d) => d.count), 1)
})
const chartHeight = svgHeight - padding.top - padding.bottom
// Grid lines (5 horizontal lines)
const gridLines = computed(() => {
const lines = []
for (let i = 0; i <= 4; i++) {
lines.push(padding.top + (chartHeight / 4) * i)
}
return lines
})
const yLabels = computed(() => {
const labels = []
for (let i = 0; i <= 4; i++) {
const y = padding.top + (chartHeight / 4) * i
const value = Math.round(maxCount.value - (maxCount.value / 4) * i)
labels.push({ y, text: value.toString() })
}
return labels
})
const barWidth = computed(() => {
const count = filteredData.value.length
if (count === 0) return 0
const availableWidth = svgWidth - padding.left - padding.right
const maxBarWidth = 32
const calculatedWidth = (availableWidth / count) * 0.7
return Math.min(calculatedWidth, maxBarWidth)
})
const visibleBars = computed(() => {
const count = filteredData.value.length
if (count === 0) return []
const availableWidth = svgWidth - padding.left - padding.right
const gap = count > 1 ? (availableWidth - barWidth.value * count) / (count - 1) : 0
return filteredData.value.map((item, idx) => {
const x = padding.left + idx * (barWidth.value + gap)
const barHeight = maxCount.value > 0 ? (item.count / maxCount.value) * chartHeight : 0
const y = svgHeight - padding.bottom - barHeight
// Color gradient based on value
const ratio = maxCount.value > 0 ? item.count / maxCount.value : 0
let color: string
if (ratio > 0.8) color = '#6366f1' // indigo-500
else if (ratio > 0.5) color = '#818cf8' // indigo-400
else if (ratio > 0.3) color = '#a5b4fc' // indigo-300
else color = '#c7d2fe' // indigo-200
// Tooltip
const tooltipText = `${item.count} ${formatDate(item.date)}`
const tooltipWidth = tooltipText.length * 7 + 16
let tooltipX = x + barWidth.value / 2 - tooltipWidth / 2
if (tooltipX < padding.left) tooltipX = padding.left
if (tooltipX + tooltipWidth > svgWidth - padding.right) tooltipX = svgWidth - padding.right - tooltipWidth
const tooltipY = y - 4
return {
x,
y,
height: barHeight,
color,
tooltipX,
tooltipY,
tooltipWidth,
tooltipText,
}
})
})
// X-axis labels (show every Nth)
const xLabels = computed(() => {
const count = filteredData.value.length
if (count === 0) return []
const availableWidth = svgWidth - padding.left - padding.right
const gap = count > 1 ? (availableWidth - barWidth.value * count) / (count - 1) : 0
// Determine label interval based on data density
let interval = 1
if (count > 14) interval = 2
if (count > 30) interval = 5
if (count > 60) interval = 10
return filteredData.value
.map((item, idx) => {
const x = padding.left + idx * (barWidth.value + gap)
const d = new Date(item.date)
const label = `${d.getMonth() + 1}/${d.getDate()}`
return { x, label, idx }
})
.filter((_, i) => i % interval === 0 || i === count - 1)
})
// Summary stats
const averageCount = computed(() => {
if (filteredData.value.length === 0) return 0
const sum = filteredData.value.reduce((acc, d) => acc + d.count, 0)
return Math.round(sum / filteredData.value.length)
})
const totalCount = computed(() => {
return filteredData.value.reduce((acc, d) => acc + d.count, 0)
})
const trendDirection = computed(() => {
const data = filteredData.value
if (data.length < 4) return 'stable'
const half = Math.floor(data.length / 2)
const firstHalf = data.slice(0, half).reduce((acc, d) => acc + d.count, 0) / half
const secondHalf = data.slice(half).reduce((acc, d) => acc + d.count, 0) / (data.length - half)
if (secondHalf > firstHalf * 1.05) return 'up'
if (secondHalf < firstHalf * 0.95) return 'down'
return 'stable'
})
const trendPercent = computed(() => {
const data = filteredData.value
if (data.length < 4) return 0
const half = Math.floor(data.length / 2)
const firstHalf = data.slice(0, half).reduce((acc, d) => acc + d.count, 0) / half
if (firstHalf === 0) return 100
const secondHalf = data.slice(half).reduce((acc, d) => acc + d.count, 0) / (data.length - half)
return Math.round(Math.abs((secondHalf - firstHalf) / firstHalf) * 100)
})
// ── Helpers ────────────────────────────────────────────────────────
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr)
return `${d.getMonth() + 1}/${d.getDate()}`
} catch {
return dateStr
}
}
function updateChart() {
// Trigger reactivity by clearing and resetting hover
hoveredBar.value = null
}
// ── Watch for data changes ─────────────────────────────────────────
watch(() => props.data, () => {
updateChart()
})
</script>

View File

@@ -0,0 +1,146 @@
<template>
<div class="space-y-6">
<!-- RBAC guard: hide content without fleet:view -->
<template v-if="hasOrgPermission('fleet:view')">
<div class="flex items-center justify-between">
<div class="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="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-white">{{ $t('garages.fleet.title') }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ $t('garages.fleet.total_vehicles', { count: fleetVehicles.length }) }}</p>
</div>
</div>
</div>
<div class="bg-slate-800/50 border border-slate-700 rounded-xl overflow-hidden">
<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.fleet.plate') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.fleet.brand_model') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.fleet.year') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.fleet.vin') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ $t('garages.fleet.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="v in fleetVehicles" :key="v.id" class="border-b border-slate-700/50 last:border-b-0 hover:bg-slate-700/30 transition">
<td class="px-4 py-3">
<span class="text-sm font-bold text-white">{{ v.license_plate || '—' }}</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-slate-300">{{ v.brand || '—' }}</span>
<span v-if="v.brand && v.model" class="text-slate-500 mx-1">·</span>
<span v-if="v.model" class="text-sm text-slate-300">{{ v.model }}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ v.year || '—' }}</td>
<td class="px-4 py-3">
<span class="text-xs font-mono text-slate-400">{{ v.vin || '—' }}</span>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="fleetStatusBadgeClass(v.status)"
>
<span class="w-1.5 h-1.5 rounded-full" :class="fleetStatusDotClass(v.status)"></span>
{{ fleetStatusLabel(v.status) }}
</span>
</td>
</tr>
<tr v-if="fleetVehicles.length === 0 && !fleetLoading">
<td colspan="5" class="px-4 py-12 text-center">
<svg class="w-12 h-12 text-slate-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0" />
</svg>
<p class="text-sm text-slate-400">{{ $t('garages.fleet.no_vehicles') }}</p>
</td>
</tr>
<tr v-if="fleetLoading">
<td colspan="5" class="px-4 py-12 text-center">
<svg class="w-8 h-8 text-indigo-400 animate-spin mx-auto" 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>
<p class="text-sm text-slate-400 mt-2">{{ $t('garages.details.loading') }}</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- RBAC: No fleet access fallback -->
<template v-else>
<div class="bg-slate-800/50 border border-slate-700 rounded-xl p-12 text-center">
<svg class="w-12 h-12 text-slate-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<p class="text-sm text-slate-400">{{ $t('garages.fleet.no_fleet_access') }}</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '~/stores/auth'
const props = defineProps<{
garage: any
orgId: string
fleetVehicles: any[]
fleetLoading: boolean
}>()
const auth = useAuthStore()
// RBAC helper
function hasOrgPermission(perm: string): boolean {
if (auth.isAdmin) return true
if (auth.user?.system_capabilities?.[perm]) return true
if (auth.user?.org_capabilities?.[props.orgId]?.[perm]) return true
return false
}
function fleetStatusLabel(status: string): string {
const map: Record<string, string> = {
active: 'Active',
inactive: 'Inactive',
sold: 'Sold',
scrapped: 'Scrapped',
pending: 'Pending',
draft: 'Draft',
}
return map[status?.toLowerCase()] || status || 'Unknown'
}
function fleetStatusBadgeClass(status: string): string {
const map: Record<string, string> = {
active: 'bg-green-500/10 text-green-400',
inactive: 'bg-gray-500/10 text-gray-400',
sold: 'bg-red-500/10 text-red-400',
scrapped: 'bg-red-500/10 text-red-400',
pending: 'bg-yellow-500/10 text-yellow-400',
draft: 'bg-gray-500/10 text-gray-400',
}
return map[status?.toLowerCase()] || 'bg-gray-500/10 text-gray-400'
}
function fleetStatusDotClass(status: string): string {
const map: Record<string, string> = {
active: 'bg-green-400',
inactive: 'bg-gray-400',
sold: 'bg-red-400',
scrapped: 'bg-red-400',
pending: 'bg-yellow-400',
draft: 'bg-gray-400',
}
return map[status?.toLowerCase()] || 'bg-gray-400'
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<div class="space-y-6">
<!-- Company Info -->
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<h3 class="text-base font-semibold text-white">{{ $t('garages.details.company_info') }}</h3>
</div>
<div class="px-6 py-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.company_name') }}</p>
<p class="text-sm text-white mt-1">{{ garage.full_name }}</p>
</div>
<div>
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.display_name') }}</p>
<p class="text-sm text-white mt-1">{{ garage.display_name || '—' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.org_type') }}</p>
<p class="text-sm text-white mt-1">{{ orgTypeLabel(garage.org_type) }}</p>
</div>
</div>
<div class="space-y-4">
<div>
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.tax_number') }}</p>
<p class="text-sm text-white mt-1">{{ garage.tax_number || '—' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.reg_number') }}</p>
<p class="text-sm text-white mt-1">{{ garage.reg_number || '—' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.created_at') }}</p>
<p class="text-sm text-white mt-1">{{ formatDate(garage.created_at) }}</p>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-slate-700/50">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">{{ $t('garages.details.address') }}</p>
<p class="text-sm text-white">{{ formattedAddress }}</p>
</div>
<!-- Billing Address -->
<div v-if="formattedBillingAddress" class="mt-4 pt-4 border-t border-slate-700/50">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">{{ $t('garages.details.billing_address') }}</p>
<p class="text-sm text-white">{{ formattedBillingAddress }}</p>
</div>
<!-- Notification Address -->
<div v-if="formattedNotificationAddress" class="mt-4 pt-4 border-t border-slate-700/50">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">{{ $t('garages.details.notification_address') }}</p>
<p class="text-sm text-white">{{ formattedNotificationAddress }}</p>
</div>
<!-- System IDs Footer -->
<div class="mt-6 pt-4 border-t border-slate-700/50">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">Rendszer Azonosítók</p>
<div class="flex gap-6 text-xs font-mono text-slate-500">
<span>Garázs ID: <span class="text-slate-400">{{ garage.id }}</span></span>
<span>User ID: <span class="text-slate-400">{{ garage.owner_user_id ?? 'N/A' }}</span></span>
<span>Person ID: <span class="text-slate-400">{{ garage.owner_person_id ?? 'N/A' }}</span></span>
</div>
</div>
</div>
</div>
<!-- Contact Info -->
<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-emerald-500/10 text-emerald-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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 class="text-base font-semibold text-white">{{ $t('garages.details.contact_info') }}</h3>
</div>
<div class="px-6 py-5">
<!-- Show block if ANY contact data exists (top-level fields or primary_contact) -->
<div v-if="garage.contact_person_name || garage.contact_email || garage.contact_phone || garage.primary_contact" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.contact_name') }}</p>
<p class="text-sm text-white mt-1 font-medium">{{ garage.contact_person_name || garage.primary_contact?.full_name || '—' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.contact_email') }}</p>
<p class="text-sm text-white mt-1 flex items-center gap-1.5">
<svg class="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>{{ garage.contact_email || garage.primary_contact?.email || '—' }}</span>
</p>
</div>
</div>
<div class="space-y-4">
<div>
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.contact_phone') }}</p>
<p class="text-sm text-white mt-1 flex items-center gap-1.5">
<svg class="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span>{{ garage.contact_phone || garage.primary_contact?.phone || '—' }}</span>
</p>
</div>
<!-- Extra metadata from primary_contact if available -->
<div v-if="garage.primary_contact?.role || garage.primary_contact?.department">
<p class="text-xs text-slate-500 uppercase tracking-wider">{{ $t('garages.details.contact_role') }}</p>
<p class="text-sm text-white mt-1">{{ garage.primary_contact.role || '—' }}<span v-if="garage.primary_contact?.department"> · {{ garage.primary_contact.department }}</span></p>
</div>
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-sm text-slate-400">{{ $t('garages.details.no_contact') }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
garage: any
}>()
function orgTypeLabel(type: string): string {
const map: Record<string, string> = {
dealership: 'Dealership',
service: 'Service Center',
rental: 'Rental Agency',
fleet: 'Fleet Operator',
individual: 'Individual',
workshop: 'Workshop',
garage: 'Garage',
other: 'Other',
}
return map[type?.toLowerCase()] || type || '-'
}
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
}
}
const formattedAddress = computed(() => {
if (!props.garage) return ''
const parts = [
props.garage.address_zip,
props.garage.address_city,
props.garage.address_street_name,
props.garage.address_street_type,
props.garage.address_house_number,
].filter(Boolean)
return parts.join(' ') || '-'
})
const formattedBillingAddress = computed(() => {
if (!props.garage) return ''
const parts = [
props.garage.billing_zip,
props.garage.billing_city,
props.garage.billing_street_name,
props.garage.billing_street_type,
props.garage.billing_house_number,
].filter(Boolean)
return parts.join(' ') || ''
})
const formattedNotificationAddress = computed(() => {
if (!props.garage) return ''
const parts = [
props.garage.notification_zip,
props.garage.notification_city,
props.garage.notification_street_name,
props.garage.notification_street_type,
props.garage.notification_house_number,
].filter(Boolean)
return parts.join(' ') || ''
})
</script>

View File

@@ -0,0 +1,703 @@
<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>