1394 lines
60 KiB
Vue
1394 lines
60 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 Content (shown when packages exist) -->
|
||
<div v-else class="space-y-6 mb-8">
|
||
<!-- Package Type Tabs (Base vs Add-on) -->
|
||
<div class="mb-6">
|
||
<div class="flex gap-2 border-b border-slate-700">
|
||
<button
|
||
@click="listTab = 'base'"
|
||
class="px-5 py-3 text-sm font-medium border-b-2 transition"
|
||
:class="listTab === 'base'
|
||
? 'border-indigo-500 text-indigo-300'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-500'"
|
||
>
|
||
<span class="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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||
</svg>
|
||
Alapcsomagok
|
||
</span>
|
||
</button>
|
||
<button
|
||
@click="listTab = 'addon'"
|
||
class="px-5 py-3 text-sm font-medium border-b-2 transition"
|
||
:class="listTab === 'addon'
|
||
? 'border-amber-500 text-amber-300'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-500'"
|
||
>
|
||
<span class="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 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
Kiegészítők
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Package Tier Cards Grid -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||
<div
|
||
v-for="pkg in listTab === 'base' ? basePackages : addonPackages"
|
||
: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.max_users') }}</span>
|
||
<span class="text-slate-200 font-medium">{{ getAllowance(pkg, 'max_users') }}</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> <!-- /grid -->
|
||
</div> <!-- /package-content-wrapper -->
|
||
|
||
<!-- ── 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>
|
||
|
||
<!-- 🎯 GLOBAL TYPE SELECTOR (above tabs, below title) -->
|
||
<div class="px-6 pt-4 pb-2 mb-6">
|
||
<div class="bg-slate-700/40 border border-slate-600 rounded-xl p-4">
|
||
<label class="block text-sm font-semibold text-slate-200 mb-3">{{ $t('packages.form_type') }}</label>
|
||
<div class="flex gap-4">
|
||
<label
|
||
class="flex-1 flex items-center gap-3 px-4 py-3 rounded-lg border-2 cursor-pointer transition"
|
||
:class="form.type === 'base' || form.type === 'private' || form.type === 'corporate'
|
||
? 'border-indigo-500 bg-indigo-500/10'
|
||
: 'border-slate-600 bg-slate-700/50 hover:border-slate-500'"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="packageType"
|
||
value="private"
|
||
v-model="form.type"
|
||
class="sr-only"
|
||
/>
|
||
<div
|
||
class="w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0"
|
||
:class="form.type === 'private' || form.type === 'corporate' || form.type === 'base'
|
||
? 'border-indigo-500'
|
||
: 'border-slate-500'"
|
||
>
|
||
<div
|
||
v-if="form.type === 'private' || form.type === 'corporate' || form.type === 'base'"
|
||
class="w-2.5 h-2.5 rounded-full bg-indigo-500"
|
||
></div>
|
||
</div>
|
||
<div>
|
||
<span class="text-sm font-medium text-white">📦 Alapcsomag (Base Tier)</span>
|
||
<p class="text-xs text-slate-400 mt-0.5">Teljes jogú csomag járművekkel, garázsokkal, felhasználókkal</p>
|
||
</div>
|
||
</label>
|
||
<label
|
||
class="flex-1 flex items-center gap-3 px-4 py-3 rounded-lg border-2 cursor-pointer transition"
|
||
:class="form.type === 'addon'
|
||
? 'border-amber-500 bg-amber-500/10'
|
||
: 'border-slate-600 bg-slate-700/50 hover:border-slate-500'"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="packageType"
|
||
value="addon"
|
||
v-model="form.type"
|
||
class="sr-only"
|
||
/>
|
||
<div
|
||
class="w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0"
|
||
:class="form.type === 'addon' ? 'border-amber-500' : 'border-slate-500'"
|
||
>
|
||
<div
|
||
v-if="form.type === 'addon'"
|
||
class="w-2.5 h-2.5 rounded-full bg-amber-500"
|
||
></div>
|
||
</div>
|
||
<div>
|
||
<span class="text-sm font-medium text-white">➕ Kiegészítő (Add-on)</span>
|
||
<p class="text-xs text-slate-400 mt-0.5">Kiegészítő modul meglévő csomaghoz, korlátozott opciókkal</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</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">
|
||
<!-- System Name: free-text input for base, dropdown for addon -->
|
||
<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>
|
||
|
||
<!-- Tier Level (hidden for add-on) -->
|
||
<div v-if="form.type !== 'addon'">
|
||
<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>
|
||
|
||
<!-- Subtitle & Badge (hidden for add-on) -->
|
||
<div v-if="form.type !== 'addon'" 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 (hidden for add-on) -->
|
||
<div v-if="form.type !== 'addon'" 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">
|
||
<div class="flex items-center gap-1">
|
||
<button
|
||
@click="applyDefaultPricingToZone(region.country_code)"
|
||
class="p-1.5 rounded-lg text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10 transition"
|
||
:title="'Apply DEFAULT pricing to ' + 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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||
</svg>
|
||
</button>
|
||
<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>
|
||
</div>
|
||
</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.max_users') }}</label>
|
||
<input
|
||
v-model.number="form.max_users"
|
||
type="number"
|
||
min="1"
|
||
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
|
||
max_users: 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
|
||
max_users: 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 : ''
|
||
})
|
||
|
||
// ── List Tab State (Base vs Add-on) ────────────────────────────────
|
||
|
||
const listTab = ref<'base' | 'addon'>('base')
|
||
|
||
const basePackages = computed(() => packages.value.filter(p => p.rules?.type !== 'addon'))
|
||
const addonPackages = computed(() => packages.value.filter(p => p.rules?.type === 'addon'))
|
||
|
||
// ── 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,
|
||
max_users: 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`)
|
||
}
|
||
|
||
/**
|
||
* Copy DEFAULT zone pricing values to the specified region.
|
||
* Uses Object.assign for Vue reactivity to ensure the UI updates immediately.
|
||
*/
|
||
function applyDefaultPricingToZone(countryCode: string) {
|
||
const defaultZone = form.pricing_zones['DEFAULT']
|
||
if (!defaultZone) {
|
||
showNotification('error', 'No DEFAULT pricing zone exists. Set DEFAULT prices first.')
|
||
return
|
||
}
|
||
if (countryCode === 'DEFAULT') return
|
||
// Ensure the target zone object exists
|
||
if (!form.pricing_zones[countryCode]) {
|
||
form.pricing_zones[countryCode] = { monthly_price: 0, yearly_price: 0, credit_price: 0, currency: 'EUR' }
|
||
}
|
||
// Copy DEFAULT values into the target zone using Object.assign for reactivity
|
||
const target = form.pricing_zones[countryCode]
|
||
Object.assign(target, {
|
||
monthly_price: defaultZone.monthly_price,
|
||
yearly_price: defaultZone.yearly_price,
|
||
credit_price: defaultZone.credit_price ?? 0,
|
||
currency: defaultZone.currency,
|
||
})
|
||
showNotification('success', `Applied DEFAULT pricing to ${countryCode} 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?include_hidden=true', {
|
||
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)
|
||
// Context-aware type: if on the Add-on tab, default to addon type
|
||
if (listTab.value === 'addon') {
|
||
form.type = 'addon'
|
||
}
|
||
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.max_users = allowances.max_users || 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.max_users = allowances.max_users || 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,
|
||
max_users: form.max_users,
|
||
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> |