admin frontend elkezdése, járművek tisztázása, frontend fejleszts
This commit is contained in:
482
frontend/src/components/admin/PackageEditModal.vue
Normal file
482
frontend/src/components/admin/PackageEditModal.vue
Normal file
@@ -0,0 +1,482 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user