Files
service-finder/frontend_admin/pages/packages/index.vue
2026-06-29 14:11:15 +00:00

1394 lines
60 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<!-- 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>