Files
service-finder/frontend_admin/pages/packages/index.vue

1237 lines
53 KiB
Vue

<template>
<div>
<!-- Page Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white">{{ $t('packages.title') }}</h1>
<p class="text-slate-400 mt-1">{{ $t('packages.subtitle') }}</p>
</div>
<button
@click="openCreateModal"
class="px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-medium rounded-lg transition text-sm flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ $t('packages.create_new') }}
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="flex flex-col items-center gap-3">
<svg class="animate-spin h-8 w-8 text-indigo-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span class="text-slate-400 text-sm">{{ $t('packages.loading') }}</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-red-900/30 border border-red-700 rounded-xl p-6 text-center">
<p class="text-red-300 mb-3">{{ error }}</p>
<button
@click="fetchPackages"
class="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-lg transition text-sm"
>
{{ $t('packages.retry') }}
</button>
</div>
<!-- Empty State -->
<div v-else-if="packages.length === 0" class="bg-slate-800/50 border border-slate-700 rounded-xl p-12 text-center">
<svg class="w-16 h-16 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<h3 class="text-lg font-medium text-slate-300 mb-2">{{ $t('packages.no_packages') }}</h3>
<p class="text-slate-500 mb-6">{{ $t('packages.no_packages_desc') }}</p>
<button
@click="openCreateModal"
class="px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-medium rounded-lg transition text-sm"
>
{{ $t('packages.create_first') }}
</button>
</div>
<!-- Package Tier Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 mb-8">
<div
v-for="pkg in packages"
:key="pkg.id"
class="bg-slate-800 rounded-xl border border-slate-700 p-6 flex flex-col transition-all hover:border-slate-600"
>
<!-- Card Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div
class="p-2 rounded-lg"
:class="getTierColorClass(pkg.tier_level)"
>
<span class="w-5 h-5 text-lg" v-html="getTierIcon(pkg.tier_level)" />
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ getDisplayName(pkg) }}</h2>
<span class="text-xs text-slate-500 font-mono">{{ pkg.name }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 flex-wrap">
<span
v-if="pkg.tier_level >= 1"
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-indigo-500/20 text-indigo-300"
>
{{ $t('packages.popular') }}
</span>
<span
v-if="pkg.is_default_fallback"
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-amber-500/20 text-amber-300"
title="Default fallback package for expired subscriptions"
>
🔒 {{ $t('packages.fallback_badge') }}
</span>
<span
v-if="pkg.trial_days_on_signup > 0"
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-emerald-500/20 text-emerald-300"
:title="`${pkg.trial_days_on_signup} days trial on signup`"
>
🎯 {{ $t('packages.trial_badge') }}: {{ pkg.trial_days_on_signup }}d
</span>
</div>
</div>
<!-- Price -->
<div class="mb-4">
<span class="text-3xl font-bold text-white">{{ getFormattedPrice(pkg) }}</span>
<span class="text-sm text-slate-400 ml-1">/ {{ $t('packages.month') }}</span>
</div>
<!-- Description / Subtitle -->
<p class="text-sm text-slate-400 mb-4 flex-1">{{ getDescription(pkg) }}</p>
<!-- Allowances -->
<div class="border-t border-slate-700 pt-4 mb-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-3">
{{ $t('packages.allowances') }}
</h3>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-slate-400">{{ $t('packages.max_vehicles') }}</span>
<span class="text-slate-200 font-medium">{{ getAllowance(pkg, 'max_vehicles') }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-400">{{ $t('packages.max_garages') }}</span>
<span class="text-slate-200 font-medium">{{ getAllowance(pkg, 'max_garages') }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-400">{{ $t('packages.monthly_credits') }}</span>
<span class="text-slate-200 font-medium">{{ getAllowance(pkg, 'monthly_free_credits') }}</span>
</div>
</div>
</div>
<!-- Tier Level Badge -->
<div class="mb-4">
<span class="text-xs text-slate-500">{{ $t('packages.tier_level') }}: </span>
<span class="text-xs font-mono text-slate-400">{{ pkg.tier_level }}</span>
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
@click="openEditModal(pkg)"
class="flex-1 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition text-sm"
>
{{ $t('packages.edit') }}
</button>
<button
@click="duplicatePackage(pkg)"
class="px-3 py-2.5 bg-emerald-700 hover:bg-emerald-600 text-emerald-200 rounded-lg transition"
:title="$t('packages.duplicate')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button
@click="confirmDelete(pkg)"
class="px-3 py-2.5 bg-red-700 hover:bg-red-600 text-red-200 rounded-lg transition"
:title="$t('packages.delete')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
<!-- Create / Edit Modal (Tabbed Layout) -->
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
@click.self="showModal = false"
>
<div class="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-3xl mx-4 shadow-2xl max-h-[90vh] flex flex-col">
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-700">
<h3 class="text-lg font-semibold text-white">
{{ isCreating ? $t('packages.create_title') : ($t('packages.edit_title') + ': ' + getDisplayName(editingPackage!)) }}
</h3>
<button
@click="showModal = false"
class="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Tab Navigation -->
<div class="flex border-b border-slate-700 px-6">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="px-4 py-3 text-sm font-medium border-b-2 transition"
:class="activeTab === tab.key
? 'border-indigo-500 text-indigo-300'
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-500'"
>
{{ tab.label }}
</button>
</div>
<!-- Tab Content (scrollable) -->
<div class="flex-1 overflow-y-auto px-6 py-5">
<!-- TAB 1: Basic Info -->
<div v-if="activeTab === 'basic'" class="space-y-4">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_name') }}</label>
<input
v-model="form.name"
type="text"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="$t('packages.form_name_placeholder')"
/>
</div>
<!-- Display Name -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_display_name') }}</label>
<input
v-model="form.display_name"
type="text"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="$t('packages.form_display_name_placeholder')"
/>
</div>
<!-- Type & Tier Level -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_type') }}</label>
<select
v-model="form.type"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="private">{{ $t('packages.type_private') }}</option>
<option value="corporate">{{ $t('packages.type_corporate') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_tier_level') }}</label>
<input
v-model.number="form.tier_level"
type="number"
min="0"
max="10"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<p class="text-xs text-slate-500 mt-1">{{ $t('packages.form_tier_level_hint') }}</p>
</div>
</div>
<!-- Subtitle & Badge -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_subtitle') }}</label>
<input
v-model="form.subtitle"
type="text"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="$t('packages.form_subtitle_placeholder')"
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ $t('packages.form_badge') }}</label>
<input
v-model="form.badge"
type="text"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="$t('packages.form_badge_placeholder')"
/>
</div>
</div>
<!-- Is Custom -->
<div class="flex items-center gap-3">
<input
v-model="form.is_custom"
type="checkbox"
id="is_custom"
class="rounded bg-slate-700 border-slate-600 text-indigo-600 focus:ring-indigo-500"
/>
<label for="is_custom" class="text-sm text-slate-300">{{ $t('packages.form_is_custom') }}</label>
</div>
<!-- Singleton Rules -->
<div class="border-t border-slate-700 pt-4">
<h4 class="text-sm font-semibold text-slate-300 mb-3">{{ $t('packages.singleton_rules') }}</h4>
<div class="space-y-4">
<div class="flex items-center gap-3">
<input
v-model="form.is_default_fallback"
type="checkbox"
id="is_default_fallback"
class="rounded bg-slate-700 border-slate-600 text-amber-500 focus:ring-amber-500"
/>
<label for="is_default_fallback" class="text-sm text-slate-300">
{{ $t('packages.form_is_default_fallback') }}
</label>
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">{{ $t('packages.form_trial_days') }}</label>
<input
v-model.number="form.trial_days_on_signup"
type="number"
min="0"
max="365"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
/>
<p class="text-xs text-slate-500 mt-1">{{ $t('packages.form_trial_days_hint') }}</p>
</div>
</div>
</div>
</div>
<!-- TAB 2: Pricing (Per-Region + Smart Calculator) -->
<div v-if="activeTab === 'pricing'" class="space-y-6">
<!-- Per-Region Pricing Grid -->
<div>
<h4 class="text-sm font-semibold text-slate-300 mb-3">{{ $t('packages.per_region_pricing') }}</h4>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left py-2 px-3 text-slate-400 font-medium">Region</th>
<th class="text-left py-2 px-3 text-slate-400 font-medium">{{ $t('packages.form_monthly_price') }}</th>
<th class="text-left py-2 px-3 text-slate-400 font-medium">{{ $t('packages.form_yearly_price') }}</th>
<th class="text-left py-2 px-3 text-slate-400 font-medium">Kredit Ár</th>
<th class="text-left py-2 px-3 text-slate-400 font-medium">{{ $t('packages.form_currency') }}</th>
<th class="text-left py-2 px-3 text-slate-400 font-medium"></th>
</tr>
</thead>
<tbody>
<tr v-for="region in regionStore.regions" :key="region.country_code" class="border-b border-slate-700/50">
<td class="py-2 px-3 text-slate-200 font-medium">
<span class="inline-flex items-center gap-1.5">
<span>{{ region.country_code }}</span>
<span class="text-xs text-slate-500">({{ region.currency }}, {{ region.default_vat_rate }}% VAT)</span>
</span>
</td>
<td class="py-2 px-3">
<input
:value="getZonePrice(region.country_code, 'monthly')"
@input="setZonePrice(region.country_code, 'monthly', ($event.target as HTMLInputElement).value)"
type="number"
step="0.01"
min="0"
class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</td>
<td class="py-2 px-3">
<input
:value="getZonePrice(region.country_code, 'yearly')"
@input="setZonePrice(region.country_code, 'yearly', ($event.target as HTMLInputElement).value)"
type="number"
step="0.01"
min="0"
class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</td>
<td class="py-2 px-3">
<input
:value="getZoneCreditPrice(region.country_code)"
@input="setZoneCreditPrice(region.country_code, ($event.target as HTMLInputElement).value)"
type="number"
step="0.01"
min="0"
class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</td>
<td class="py-2 px-3">
<input
:value="getZoneCurrency(region.country_code)"
@input="setZoneCurrency(region.country_code, ($event.target as HTMLInputElement).value)"
type="text"
maxlength="3"
class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent uppercase"
/>
</td>
<td class="py-2 px-3 text-center">
<button
@click="removeZone(region.country_code)"
class="p-1.5 rounded-lg text-red-400 hover:text-red-300 hover:bg-red-500/10 transition"
:title="'Remove ' + region.country_code"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add Zone Dropdown + Button -->
<div class="flex items-center gap-3 mt-4">
<select
v-model="selectedAddZoneCode"
class="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="" disabled>{{ $t('packages.add_zone_select') }}</option>
<option
v-for="region in availableRegionsToAdd"
:key="region.country_code"
:value="region.country_code"
>
{{ region.country_code }} {{ region.name }} ({{ region.currency }}, {{ region.default_vat_rate }}% VAT)
</option>
</select>
<button
@click="addZone"
:disabled="!selectedAddZoneCode"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ $t('packages.add_zone') }}
</button>
</div>
</div>
<!-- Smart Pricing Calculator -->
<div class="border-t border-slate-700 pt-5">
<h4 class="text-sm font-semibold text-slate-300 mb-3">🧮 {{ $t('packages.smart_calculator') }}</h4>
<div class="bg-slate-700/30 rounded-xl p-4 space-y-4">
<!-- Calculator Inputs -->
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('packages.calc_net_price') }}</label>
<input
v-model.number="calcNetPrice"
type="number"
step="0.01"
min="0"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
placeholder="0.00"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('packages.calc_vat_rate') }}</label>
<select
v-model="calcVatRate"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
>
<option v-for="region in regionStore.regions" :key="region.country_code" :value="region.default_vat_rate">
{{ region.country_code }} {{ region.default_vat_rate }}%
</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('packages.calc_currency') }}</label>
<select
v-model="calcCurrency"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
>
<option v-for="region in regionStore.regions" :key="region.country_code" :value="region.currency">
{{ region.currency }} ({{ region.country_code }})
</option>
</select>
</div>
</div>
<!-- Calculator Results -->
<div class="grid grid-cols-3 gap-4 pt-2">
<div class="bg-slate-800 rounded-lg p-3 text-center">
<p class="text-xs text-slate-400 mb-1">{{ $t('packages.calc_net') }}</p>
<p class="text-lg font-bold text-white">{{ formatCurrency(calcNetPrice, calcCurrency) }}</p>
</div>
<div class="bg-slate-800 rounded-lg p-3 text-center">
<p class="text-xs text-slate-400 mb-1">{{ $t('packages.calc_vat') }} ({{ calcVatRate }}%)</p>
<p class="text-lg font-bold text-amber-400">{{ formatCurrency(calcNetPrice * (calcVatRate / 100), calcCurrency) }}</p>
</div>
<div class="bg-slate-800 rounded-lg p-3 text-center ring-2 ring-emerald-500/50">
<p class="text-xs text-slate-400 mb-1">{{ $t('packages.calc_gross') }}</p>
<p class="text-lg font-bold text-emerald-400">{{ formatCurrency(calcNetPrice * (1 + calcVatRate / 100), calcCurrency) }}</p>
</div>
</div>
<!-- Quick Apply Button -->
<div class="flex justify-end pt-1">
<button
@click="applyCalculatorToActiveRegion"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{{ $t('packages.calc_apply') }}
</button>
</div>
</div>
</div>
</div>
<!-- TAB 3: Features -->
<div v-if="activeTab === 'features'" class="space-y-5">
<!-- Allowances -->
<div>
<h4 class="text-sm font-semibold text-slate-300 mb-3">{{ $t('packages.allowances') }}</h4>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('packages.max_vehicles') }}</label>
<input
v-model.number="form.max_vehicles"
type="number"
min="0"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('packages.max_garages') }}</label>
<input
v-model.number="form.max_garages"
type="number"
min="0"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ $t('packages.monthly_credits') }}</label>
<input
v-model.number="form.monthly_free_credits"
type="number"
min="0"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
</div>
<!-- SaaS Allowances (Feature Capabilities) -->
<div class="border-t border-slate-700 pt-4">
<h4 class="text-sm font-semibold text-slate-300 mb-3">SaaS Allowances</h4>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs text-slate-400 mb-1">Költségkategória mélység (max_cost_category_depth)</label>
<input
v-model.number="form.max_cost_category_depth"
type="number"
min="0"
max="10"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Statisztika (advanced_reports)</label>
<div class="flex items-center h-[38px]">
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="form.advanced_reports"
type="checkbox"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-slate-600 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
<span class="ms-3 text-sm text-slate-300">{{ form.advanced_reports ? 'Enabled' : 'Disabled' }}</span>
</label>
</div>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">PDF/CSV Export (export_data)</label>
<div class="flex items-center h-[38px]">
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="form.export_data"
type="checkbox"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-slate-600 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
<span class="ms-3 text-sm text-slate-300">{{ form.export_data ? 'Enabled' : 'Disabled' }}</span>
</label>
</div>
</div>
</div>
</div>
<!-- Feature Capabilities (JSON editor) -->
<div class="border-t border-slate-700 pt-4">
<h4 class="text-sm font-semibold text-slate-300 mb-3">{{ $t('packages.feature_capabilities') }}</h4>
<textarea
v-model="form.feature_capabilities_json"
rows="8"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm font-mono focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder='{\n "ai_analysis": true,\n "priority_support": false,\n "api_access": true\n}'
></textarea>
<p class="text-xs text-slate-500 mt-1">{{ $t('packages.feature_capabilities_hint') }}</p>
<p v-if="featureCapabilitiesError" class="text-xs text-red-400 mt-1">{{ featureCapabilitiesError }}</p>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-slate-700">
<div class="text-xs text-slate-500">
{{ activeTabLabel }}
</div>
<div class="flex items-center gap-3">
<button
@click="showModal = false"
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
>
{{ $t('packages.cancel') }}
</button>
<button
@click="savePackage"
:disabled="saving"
class="px-6 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-white font-medium rounded-lg transition text-sm flex items-center gap-2"
>
<svg v-if="saving" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ isCreating ? $t('packages.create') : $t('packages.save') }}
</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div
v-if="showDeleteConfirm && deletingPackage"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
@click.self="showDeleteConfirm = false"
>
<div class="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-md mx-4 p-6 shadow-2xl">
<h3 class="text-lg font-semibold text-white mb-2">{{ $t('packages.delete_title') }}</h3>
<p class="text-slate-400 text-sm mb-6">
{{ $t('packages.delete_confirm', { name: getDisplayName(deletingPackage) }) }}
</p>
<div class="flex items-center justify-end gap-3">
<button
@click="showDeleteConfirm = false"
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
>
{{ $t('packages.cancel') }}
</button>
<button
@click="deletePackage"
:disabled="saving"
class="px-6 py-2 bg-red-600 hover:bg-red-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-white font-medium rounded-lg transition text-sm"
>
{{ $t('packages.delete_btn') }}
</button>
</div>
</div>
</div>
<!-- Save Notification -->
<div
v-if="saveNotification"
class="fixed bottom-6 right-6 px-5 py-3 rounded-xl shadow-xl text-sm font-medium z-50"
:class="saveNotification.type === 'success'
? 'bg-emerald-600 text-white'
: 'bg-red-600 text-white'"
>
{{ saveNotification.message }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRegionStore } from '~/stores/region'
import { useFormatter } from '~/composables/useFormatter'
definePageMeta({
middleware: 'auth',
})
// ── Region Store & Formatter ──────────────────────────────────────
const regionStore = useRegionStore()
const { formatCurrency } = useFormatter()
// ── Types ──────────────────────────────────────────────────────────
interface SubscriptionTier {
id: number
name: string
rules: {
type: string
display_name?: string | null
pricing?: {
monthly_price: number
yearly_price: number
currency: string
credit_price?: number | null
} | null
pricing_zones?: Record<string, {
monthly_price: number
yearly_price: number
currency: string
credit_price?: number | null
}> | null
allowances?: {
max_vehicles: number
max_garages: number
monthly_free_credits: number
} | null
marketing?: {
subtitle?: string | null
badge?: string | null
highlight_color?: string | null
} | null
lifecycle?: {
is_public?: boolean
available_until?: string | null
} | null
}
is_custom: boolean
tier_level: number
feature_capabilities: Record<string, any>
is_default_fallback: boolean
trial_days_on_signup: number
}
interface PackageForm {
name: string
display_name: string
type: string
tier_level: number
monthly_price: number
yearly_price: number
currency: string
max_vehicles: number
max_garages: number
monthly_free_credits: number
max_cost_category_depth: number
advanced_reports: boolean
export_data: boolean
subtitle: string
badge: string
is_custom: boolean
is_default_fallback: boolean
trial_days_on_signup: number
feature_capabilities_json: string
pricing_zones: Record<string, { monthly_price: number; yearly_price: number; credit_price: number; currency: string }>
}
// ── Tab Configuration ──────────────────────────────────────────────
const tabs = [
{ key: 'basic', label: '📋 Basic Info' },
{ key: 'pricing', label: '💰 Pricing' },
{ key: 'features', label: '⚡ Features' },
]
const activeTab = ref('basic')
const activeTabLabel = computed(() => {
const tab = tabs.find(t => t.key === activeTab.value)
return tab ? tab.label : ''
})
// ── State ──────────────────────────────────────────────────────────
const packages = ref<SubscriptionTier[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showModal = ref(false)
const showDeleteConfirm = ref(false)
const editingPackage = ref<SubscriptionTier | null>(null)
const deletingPackage = ref<SubscriptionTier | null>(null)
const isCreating = ref(false)
const saving = ref(false)
const saveNotification = ref<{ type: 'success' | 'error'; message: string } | null>(null)
const featureCapabilitiesError = ref<string | null>(null)
// ── Add Zone State ─────────────────────────────────────────────────
const selectedAddZoneCode = ref('')
const availableRegionsToAdd = computed(() => {
return regionStore.regions.filter(r => {
return r.country_code !== 'DEFAULT' && !form.pricing_zones[r.country_code]
})
})
function addZone() {
if (!selectedAddZoneCode.value) return
const code = selectedAddZoneCode.value
if (form.pricing_zones[code]) {
showNotification('error', `Region ${code} already exists in pricing zones`)
return
}
const region = regionStore.getRegion(code)
form.pricing_zones[code] = {
monthly_price: 0,
yearly_price: 0,
credit_price: 0,
currency: region?.currency || 'EUR',
}
selectedAddZoneCode.value = ''
showNotification('success', `Added pricing zone for ${code}`)
}
function removeZone(countryCode: string) {
if (countryCode === 'DEFAULT') return
delete form.pricing_zones[countryCode]
showNotification('success', `Removed pricing zone for ${countryCode}`)
}
// ── Smart Pricing Calculator State ─────────────────────────────────
const calcNetPrice = ref(0)
const calcVatRate = ref(27)
const calcCurrency = ref('EUR')
// ── Default Form ───────────────────────────────────────────────────
const defaultForm: PackageForm = {
name: '',
display_name: '',
type: 'private',
tier_level: 0,
monthly_price: 0,
yearly_price: 0,
currency: 'EUR',
max_vehicles: 1,
max_garages: 1,
monthly_free_credits: 0,
max_cost_category_depth: 3,
advanced_reports: false,
export_data: false,
subtitle: '',
badge: '',
is_custom: false,
is_default_fallback: false,
trial_days_on_signup: 0,
feature_capabilities_json: '{\n "ai_analysis": false,\n "priority_support": false,\n "api_access": false\n}',
pricing_zones: {}
}
const form = reactive<PackageForm>({ ...defaultForm })
// ── Pricing Zone Helpers ───────────────────────────────────────────
function getZonePrice(countryCode: string, period: 'monthly' | 'yearly'): number {
const zone = form.pricing_zones[countryCode]
if (!zone) return 0
return period === 'monthly' ? zone.monthly_price : zone.yearly_price
}
function setZonePrice(countryCode: string, period: 'monthly' | 'yearly', value: string) {
const num = parseFloat(value) || 0
if (!form.pricing_zones[countryCode]) {
form.pricing_zones[countryCode] = { monthly_price: 0, yearly_price: 0, credit_price: 0, currency: 'EUR' }
}
if (period === 'monthly') {
form.pricing_zones[countryCode].monthly_price = num
} else {
form.pricing_zones[countryCode].yearly_price = num
}
}
function getZoneCreditPrice(countryCode: string): number {
const zone = form.pricing_zones[countryCode]
if (!zone) return 0
return zone.credit_price ?? 0
}
function setZoneCreditPrice(countryCode: string, value: string) {
const num = parseFloat(value) || 0
if (!form.pricing_zones[countryCode]) {
form.pricing_zones[countryCode] = { monthly_price: 0, yearly_price: 0, credit_price: 0, currency: 'EUR' }
}
form.pricing_zones[countryCode].credit_price = num
}
function getZoneCurrency(countryCode: string): string {
const zone = form.pricing_zones[countryCode]
if (!zone) return 'EUR'
return zone.currency
}
function setZoneCurrency(countryCode: string, value: string) {
if (!form.pricing_zones[countryCode]) {
form.pricing_zones[countryCode] = { monthly_price: 0, yearly_price: 0, credit_price: 0, currency: 'EUR' }
}
form.pricing_zones[countryCode].currency = value.toUpperCase()
}
/**
* Apply the Smart Pricing Calculator result to the currently selected region's monthly price.
*/
function applyCalculatorToActiveRegion() {
const targetRegion = regionStore.regions.find(r => r.default_vat_rate === calcVatRate)
if (!targetRegion) {
showNotification('error', 'No region found with the selected VAT rate')
return
}
const code = targetRegion.country_code
if (!form.pricing_zones[code]) {
form.pricing_zones[code] = { monthly_price: 0, yearly_price: 0, credit_price: 0, currency: targetRegion.currency }
}
form.pricing_zones[code].monthly_price = calcNetPrice
form.pricing_zones[code].currency = calcCurrency
showNotification('success', `Applied net price ${formatCurrency(calcNetPrice, calcCurrency)} to ${code} region`)
}
// ── API Helpers ────────────────────────────────────────────────────
function getAuthHeaders() {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function fetchPackages() {
loading.value = true
error.value = null
try {
const res = await $fetch<{ total: number; tiers: SubscriptionTier[] }>('/api/v1/admin/packages', {
headers: getAuthHeaders(),
})
packages.value = res.tiers || []
} catch (err: any) {
error.value = err?.data?.detail || err?.message || 'Failed to load packages'
console.error('[Packages] Fetch error:', err)
} finally {
loading.value = false
}
}
// ── Display Helpers ────────────────────────────────────────────────
function getDisplayName(pkg: SubscriptionTier): string {
return pkg.rules?.display_name || pkg.name
}
function getDescription(pkg: SubscriptionTier): string {
return pkg.rules?.marketing?.subtitle || pkg.rules?.marketing?.badge || ''
}
function getFormattedPrice(pkg: SubscriptionTier): string {
const zones = pkg.rules?.pricing_zones
const pricing = pkg.rules?.pricing
if (zones && zones['DEFAULT']) {
const p = zones['DEFAULT']
const currency = p.currency === 'HUF' ? 'Ft' : '€'
return `${p.monthly_price} ${currency}`
}
if (pricing) {
const currency = pricing.currency === 'HUF' ? 'Ft' : '€'
return `${pricing.monthly_price} ${currency}`
}
return '—'
}
function getAllowance(pkg: SubscriptionTier, key: string): number | string {
const allowances = pkg.rules?.allowances
if (!allowances) return '—'
return (allowances as any)[key] ?? '—'
}
function getTierColorClass(level: number): string {
if (level >= 2) return 'bg-purple-500/10 text-purple-400'
if (level >= 1) return 'bg-amber-500/10 text-amber-400'
return 'bg-slate-500/10 text-slate-400'
}
function getTierIcon(level: number): string {
if (level >= 2) return '🏢'
if (level >= 1) return '⭐'
return '🔓'
}
// ── Modal Actions ──────────────────────────────────────────────────
function openCreateModal() {
isCreating.value = true
editingPackage.value = null
activeTab.value = 'basic'
Object.assign(form, defaultForm)
showModal.value = true
}
function openEditModal(pkg: SubscriptionTier) {
isCreating.value = false
editingPackage.value = pkg
activeTab.value = 'basic'
const rules = pkg.rules || {} as any
const pricing = rules.pricing_zones?.DEFAULT || rules.pricing || {}
const allowances = rules.allowances || {}
form.name = pkg.name
form.display_name = rules.display_name || ''
form.type = rules.type || 'private'
form.tier_level = pkg.tier_level
form.monthly_price = pricing.monthly_price || 0
form.yearly_price = pricing.yearly_price || 0
form.currency = pricing.currency || 'EUR'
form.max_vehicles = allowances.max_vehicles || 1
form.max_garages = allowances.max_garages || 1
form.monthly_free_credits = allowances.monthly_free_credits || 0
form.subtitle = rules.marketing?.subtitle || ''
form.badge = rules.marketing?.badge || ''
form.is_custom = pkg.is_custom
form.is_default_fallback = pkg.is_default_fallback
form.trial_days_on_signup = pkg.trial_days_on_signup
// Load SaaS allowances from feature_capabilities
const fc = pkg.feature_capabilities || {}
form.max_cost_category_depth = fc.max_cost_category_depth ?? 3
form.advanced_reports = fc.advanced_reports ?? false
form.export_data = fc.export_data ?? false
// Load pricing zones
form.pricing_zones = {}
if (rules.pricing_zones) {
for (const [code, zone] of Object.entries(rules.pricing_zones)) {
form.pricing_zones[code] = {
monthly_price: zone.monthly_price || 0,
yearly_price: zone.yearly_price || 0,
credit_price: zone.credit_price || 0,
currency: zone.currency || 'EUR',
}
}
}
// Load feature capabilities as JSON string
form.feature_capabilities_json = JSON.stringify(pkg.feature_capabilities || {}, null, 2)
showModal.value = true
}
function confirmDelete(pkg: SubscriptionTier) {
deletingPackage.value = pkg
showDeleteConfirm.value = true
}
/**
* Duplicate a package: opens the Create Modal pre-filled with the selected tier's data.
* Clears the id and appends "_copy" to the name to avoid unique constraint violations.
*/
function duplicatePackage(pkg: SubscriptionTier) {
isCreating.value = true
editingPackage.value = null
activeTab.value = 'basic'
const rules = pkg.rules || {} as any
const pricing = rules.pricing_zones?.DEFAULT || rules.pricing || {}
const allowances = rules.allowances || {}
form.name = pkg.name + '_copy'
form.display_name = (rules.display_name || pkg.name) + ' (másolat)'
form.type = rules.type || 'private'
form.tier_level = pkg.tier_level
form.monthly_price = pricing.monthly_price || 0
form.yearly_price = pricing.yearly_price || 0
form.currency = pricing.currency || 'EUR'
form.max_vehicles = allowances.max_vehicles || 1
form.max_garages = allowances.max_garages || 1
form.monthly_free_credits = allowances.monthly_free_credits || 0
form.subtitle = rules.marketing?.subtitle || ''
form.badge = rules.marketing?.badge || ''
form.is_custom = pkg.is_custom
form.is_default_fallback = false // never duplicate fallback status
form.trial_days_on_signup = pkg.trial_days_on_signup
// Load SaaS allowances from feature_capabilities
const fc = pkg.feature_capabilities || {}
form.max_cost_category_depth = fc.max_cost_category_depth ?? 3
form.advanced_reports = fc.advanced_reports ?? false
form.export_data = fc.export_data ?? false
// Load pricing zones
form.pricing_zones = {}
if (rules.pricing_zones) {
for (const [code, zone] of Object.entries(rules.pricing_zones)) {
form.pricing_zones[code] = {
monthly_price: zone.monthly_price || 0,
yearly_price: zone.yearly_price || 0,
credit_price: zone.credit_price || 0,
currency: zone.currency || 'EUR',
}
}
}
// Load feature capabilities as JSON string
form.feature_capabilities_json = JSON.stringify(pkg.feature_capabilities || {}, null, 2)
showModal.value = true
}
// ── Save / Create ──────────────────────────────────────────────────
async function savePackage() {
if (!form.name.trim()) {
showNotification('error', 'Package name is required')
return
}
// Validate feature_capabilities JSON
let featureCapabilities: Record<string, any> = {}
try {
featureCapabilities = JSON.parse(form.feature_capabilities_json)
featureCapabilitiesError.value = null
} catch {
featureCapabilitiesError.value = 'Invalid JSON in feature capabilities'
activeTab.value = 'features'
return
}
saving.value = true
try {
// Build pricing_zones from form, ensuring DEFAULT is always present
const pricingZones: Record<string, any> = { ...form.pricing_zones }
if (!pricingZones['DEFAULT']) {
pricingZones['DEFAULT'] = {
monthly_price: form.monthly_price,
yearly_price: form.yearly_price,
credit_price: 0,
currency: form.currency.toUpperCase(),
}
}
// Merge SaaS allowances into feature_capabilities
const mergedFeatureCapabilities = {
...featureCapabilities,
max_cost_category_depth: form.max_cost_category_depth,
advanced_reports: form.advanced_reports,
export_data: form.export_data,
}
const rulesPayload: any = {
type: form.type,
display_name: form.display_name || null,
pricing_zones: pricingZones,
allowances: {
max_vehicles: form.max_vehicles,
max_garages: form.max_garages,
monthly_free_credits: form.monthly_free_credits,
},
marketing: {
subtitle: form.subtitle || null,
badge: form.badge || null,
},
lifecycle: {
is_public: true,
},
}
if (isCreating.value) {
// POST - Create new package
await $fetch('/api/v1/admin/packages', {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: {
name: form.name.trim().toLowerCase(),
rules: rulesPayload,
is_custom: form.is_custom,
tier_level: form.tier_level,
feature_capabilities: mergedFeatureCapabilities,
is_default_fallback: form.is_default_fallback,
trial_days_on_signup: form.trial_days_on_signup,
},
})
showNotification('success', `Package "${form.name}" created successfully`)
} else if (editingPackage.value) {
// PATCH - Update existing package
await $fetch(`/api/v1/admin/packages/${editingPackage.value.id}`, {
method: 'PATCH',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: {
name: form.name.trim().toLowerCase(),
rules: rulesPayload,
is_custom: form.is_custom,
tier_level: form.tier_level,
feature_capabilities: mergedFeatureCapabilities,
is_default_fallback: form.is_default_fallback,
trial_days_on_signup: form.trial_days_on_signup,
},
})
showNotification('success', `Package "${form.name}" updated successfully`)
}
showModal.value = false
await fetchPackages()
} catch (err: any) {
const detail = err?.data?.detail || err?.message || 'Operation failed'
showNotification('error', detail)
console.error('[Packages] Save error:', err)
} finally {
saving.value = false
}
}
async function deletePackage() {
if (!deletingPackage.value) return
saving.value = true
try {
await $fetch(`/api/v1/admin/packages/${deletingPackage.value.id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
})
showNotification('success', `Package "${getDisplayName(deletingPackage.value)}" deactivated`)
showDeleteConfirm.value = false
deletingPackage.value = null
await fetchPackages()
} catch (err: any) {
const detail = err?.data?.detail || err?.message || 'Delete failed'
showNotification('error', detail)
console.error('[Packages] Delete error:', err)
} finally {
saving.value = false
}
}
// ── Notification ───────────────────────────────────────────────────
function showNotification(type: 'success' | 'error', message: string) {
saveNotification.value = { type, message }
setTimeout(() => {
saveNotification.value = null
}, 4000)
}
// ── Lifecycle ──────────────────────────────────────────────────────
onMounted(() => {
fetchPackages()
regionStore.fetchRegions()
})
</script>