Files
service-finder/frontend/src/components/admin/PackageEditModal.vue

483 lines
19 KiB
Vue

<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-gray-800 border border-gray-700 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-5 border-b border-gray-700">
<h2 class="text-xl font-bold text-white">
{{ isEditing ? $t('admin.packages.modal_edit_title') : $t('admin.packages.modal_create_title') }}
</h2>
<button
class="p-2 text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded-lg transition-colors"
@click="$emit('close')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-5 space-y-6">
<!-- Display Name (human-readable) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_display_name') || 'Megjelenített név' }}
</label>
<input
v-model="form.display_name"
type="text"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Pl. Privát Ingyenes"
/>
<p class="text-xs text-gray-500 mt-1">Emberi olvasásra szánt megjelenítési név</p>
</div>
<!-- Name (technical) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_name') }}
</label>
<input
v-model="form.name"
type="text"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
:placeholder="$t('admin.packages.field_name_placeholder')"
/>
<p class="text-xs text-gray-500 mt-1">Technikai azonosító (pl. private_free_v1)</p>
</div>
<!-- Type -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_type') }}
</label>
<select
v-model="form.type"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="private">{{ $t('admin.packages.type_private') }}</option>
<option value="corporate">{{ $t('admin.packages.type_corporate') }}</option>
</select>
</div>
<!-- Pricing Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_monthly_price') }}
</label>
<input
v-model.number="form.monthly_price"
type="number"
step="0.01"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_yearly_price') }}
</label>
<input
v-model.number="form.yearly_price"
type="number"
step="0.01"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_currency') }}
</label>
<select
v-model="form.currency"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="EUR">EUR</option>
<option value="HUF">HUF</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
<!-- Allowances Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_max_vehicles') }}
</label>
<input
v-model.number="form.max_vehicles"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_max_garages') }}
</label>
<input
v-model.number="form.max_garages"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_free_credits') }}
</label>
<input
v-model.number="form.monthly_free_credits"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Credit Price (Kredit Ár) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_credit_price') }}
</label>
<input
v-model.number="form.credit_price"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<p class="text-xs text-gray-500 mt-1">Havi kredit költség a csomaghoz</p>
</div>
<!-- Lifecycle Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_is_public') }}
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="form.is_public"
type="checkbox"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-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-blue-600"></div>
<span class="ms-3 text-sm text-gray-300">{{ form.is_public ? $t('admin.packages.active') : $t('admin.packages.inactive') }}</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_available_until') }}
</label>
<input
v-model="form.available_until"
type="date"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p class="text-xs text-gray-500 mt-1">Ha üres, nincs lejárat</p>
</div>
</div>
<!-- Affiliate Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_commission_rate') }}
</label>
<input
v-model.number="form.commission_rate_percent"
type="number"
min="0"
max="100"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_referral_bonus') }}
</label>
<input
v-model.number="form.referral_bonus_credits"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Entitlements (Chip Selector) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
{{ $t('admin.packages.field_entitlements') }}
</label>
<div class="flex flex-wrap gap-2 mb-3">
<span
v-for="ent in selectedEntitlements"
:key="ent"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-900/50 text-blue-300 border border-blue-700 rounded-full text-sm"
>
{{ ent }}
<button
class="p-0.5 hover:bg-blue-800 rounded-full transition-colors"
@click="removeEntitlement(ent)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
</div>
<div class="relative">
<select
v-model="entitlementToAdd"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="addEntitlement"
>
<option value="">{{ $t('admin.packages.entitlement_placeholder') }}</option>
<option
v-for="ent in availableEntitlements"
:key="ent"
:value="ent"
:disabled="selectedEntitlements.includes(ent)"
>
{{ ent }}
</option>
</select>
</div>
</div>
<!-- Error Display -->
<div
v-if="errorMessage"
class="px-4 py-3 bg-red-900/40 border border-red-700 rounded-lg text-red-300 text-sm"
>
{{ errorMessage }}
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-700">
<button
class="px-4 py-2.5 text-sm font-medium text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
@click="$emit('close')"
>
{{ $t('admin.packages.cancel') }}
</button>
<button
class="px-4 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isFormValid"
@click="handleSave"
>
<span v-if="isSaving" class="flex items-center gap-2">
<svg 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" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ $t('admin.packages.saving') }}
</span>
<span v-else>
{{ isEditing ? $t('admin.packages.save_changes') : $t('admin.packages.create') }}
</span>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAdminPackagesStore, AVAILABLE_ENTITLEMENTS } from '../../stores/adminPackages'
import type { SubscriptionTierItem, SubscriptionRulesModel } from '../../stores/adminPackages'
const props = defineProps<{
package?: SubscriptionTierItem | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const store = useAdminPackagesStore()
const isEditing = computed(() => !!props.package)
// ── Form State ──────────────────────────────────────────────────────────────
const form = ref({
name: '',
display_name: '',
type: 'private' as 'private' | 'corporate',
monthly_price: 0,
yearly_price: 0,
currency: 'EUR',
credit_price: null as number | null,
max_vehicles: 0,
max_garages: 0,
monthly_free_credits: 0,
is_public: true,
available_until: '' as string,
commission_rate_percent: 0,
referral_bonus_credits: 0,
})
const selectedEntitlements = ref<string[]>([])
const entitlementToAdd = ref('')
const isSaving = ref(false)
const errorMessage = ref<string | null>(null)
const availableEntitlements = computed(() => {
return AVAILABLE_ENTITLEMENTS.filter((e) => !selectedEntitlements.value.includes(e))
})
const isFormValid = computed(() => {
return form.value.name.trim().length >= 2
})
// ── Initialize form when editing ────────────────────────────────────────────
watch(
() => props.package,
(pkg) => {
if (pkg) {
const rules = pkg.rules
form.value.name = pkg.name
form.value.display_name = rules.display_name || ''
form.value.type = rules.type
form.value.monthly_price = rules.pricing.monthly_price
form.value.yearly_price = rules.pricing.yearly_price
form.value.currency = rules.pricing.currency
form.value.credit_price = rules.pricing.credit_price ?? null
form.value.max_vehicles = rules.allowances.max_vehicles
form.value.max_garages = rules.allowances.max_garages
form.value.monthly_free_credits = rules.allowances.monthly_free_credits
form.value.is_public = rules.lifecycle?.is_public ?? true
form.value.available_until = rules.lifecycle?.available_until
? rules.lifecycle.available_until.slice(0, 10)
: ''
form.value.commission_rate_percent = rules.affiliate.commission_rate_percent
form.value.referral_bonus_credits = rules.affiliate.referral_bonus_credits
selectedEntitlements.value = [...rules.entitlements]
} else {
resetForm()
}
},
{ immediate: true },
)
function resetForm() {
form.value = {
name: '',
display_name: '',
type: 'private',
monthly_price: 0,
yearly_price: 0,
currency: 'EUR',
credit_price: null,
max_vehicles: 0,
max_garages: 0,
monthly_free_credits: 0,
is_public: true,
available_until: '',
commission_rate_percent: 0,
referral_bonus_credits: 0,
}
selectedEntitlements.value = []
errorMessage.value = null
}
// ── Entitlement Chip Management ─────────────────────────────────────────────
function addEntitlement() {
const val = entitlementToAdd.value
if (val && !selectedEntitlements.value.includes(val)) {
selectedEntitlements.value.push(val)
}
entitlementToAdd.value = ''
}
function removeEntitlement(ent: string) {
selectedEntitlements.value = selectedEntitlements.value.filter((e) => e !== ent)
}
// ── Build Rules Object ──────────────────────────────────────────────────────
function buildRules(): SubscriptionRulesModel {
const rules: SubscriptionRulesModel = {
type: form.value.type,
pricing: {
monthly_price: form.value.monthly_price,
yearly_price: form.value.yearly_price,
currency: form.value.currency,
},
allowances: {
max_vehicles: form.value.max_vehicles,
max_garages: form.value.max_garages,
monthly_free_credits: form.value.monthly_free_credits,
},
entitlements: [...selectedEntitlements.value],
affiliate: {
commission_rate_percent: form.value.commission_rate_percent,
referral_bonus_credits: form.value.referral_bonus_credits,
},
}
if (form.value.display_name.trim()) {
rules.display_name = form.value.display_name.trim()
}
// Credit price
if (form.value.credit_price !== null && form.value.credit_price >= 0) {
rules.pricing.credit_price = form.value.credit_price
}
// Lifecycle
const lifecycle: Record<string, any> = {
is_public: form.value.is_public,
}
if (form.value.available_until) {
lifecycle.available_until = form.value.available_until
}
rules.lifecycle = lifecycle as any
return rules
}
// ── Save Handler ────────────────────────────────────────────────────────────
async function handleSave() {
if (!isFormValid.value) return
isSaving.value = true
errorMessage.value = null
try {
const rules = buildRules()
if (isEditing.value && props.package) {
await store.updatePackage(props.package.id, {
name: form.value.name,
rules,
})
} else {
// Create: do NOT send id — PostgreSQL auto-increments it
await store.createPackage({
name: form.value.name,
rules,
})
}
emit('saved')
} catch (err: any) {
errorMessage.value = err.response?.data?.detail || store.error || 'An error occurred'
} finally {
isSaving.value = false
}
}
</script>