Files
service-finder/frontend_admin/pages/finance/commission-rules.vue
2026-07-25 10:22:03 +00:00

986 lines
45 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">
<h1 class="text-2xl font-bold text-white">{{ t('commission_rules.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('commission_rules.subtitle') }}</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="text-slate-400 flex items-center gap-3">
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{{ t('commission_rules.loading') }}</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
<p class="text-rose-400">{{ t('commission_rules.error') }}</p>
<button @click="fetchRules" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('commission_rules.retry') }}</button>
</div>
<template v-else>
<!-- Filters & Create Button -->
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<!-- Rule Type Filter -->
<select
v-model="filterType"
class="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
@change="fetchRules"
>
<option value="">{{ t('commission_rules.all_types') }}</option>
<option value="L1_REWARD">{{ t('commission_rules.l1_reward') }}</option>
<option value="L2_COMMISSION">{{ t('commission_rules.l2_commission') }}</option>
</select>
<!-- Tier Filter -->
<select
v-model="filterTier"
class="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
@change="fetchRules"
>
<option value="">{{ t('commission_rules.all_tiers') }}</option>
<option value="STANDARD">{{ t('commission_rules.tier_standard') }}</option>
<option value="VIP">{{ t('commission_rules.tier_vip') }}</option>
<option value="PLATINUM">{{ t('commission_rules.tier_platinum') }}</option>
<option value="ENTERPRISE">{{ t('commission_rules.tier_enterprise') }}</option>
<option value="CONTRACTED">{{ t('commission_rules.tier_contracted') }}</option>
</select>
<!-- Region Filter -->
<input
v-model="filterRegion"
type="text"
:placeholder="t('commission_rules.filter_region')"
maxlength="10"
class="w-28 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
@input="debouncedFetch"
/>
<!-- Active Only Toggle -->
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer select-none">
<input
v-model="filterActive"
type="checkbox"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 text-indigo-500 focus:ring-indigo-500/50"
@change="fetchRules"
/>
{{ t('commission_rules.active_only') }}
</label>
<!-- Campaign Only Toggle -->
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer select-none">
<input
v-model="filterCampaign"
type="checkbox"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 text-indigo-500 focus:ring-indigo-500/50"
@change="fetchRules"
/>
{{ t('commission_rules.campaign_only') }}
</label>
</div>
<!-- Create Button -->
<button @click="openCreateModal" class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium 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('commission_rules.create') }}
</button>
</div>
<!-- Rules Table -->
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.id') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.name') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.type') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.tier') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.region') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.campaign_dates') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.status') }}</th>
<th class="text-right px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="rule in rules" :key="rule.id" class="hover:bg-slate-700/30 transition">
<td class="px-6 py-4 text-sm text-slate-400">{{ rule.id }}</td>
<td class="px-6 py-4">
<div class="text-sm font-medium text-white">{{ rule.name }}</div>
<div v-if="rule.description" class="text-xs text-slate-500 mt-0.5 max-w-xs truncate">{{ rule.description }}</div>
</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="rule.rule_type === 'L1_REWARD' ? 'bg-amber-500/10 text-amber-400' : 'bg-blue-500/10 text-blue-400'"
>
{{ rule.rule_type === 'L1_REWARD' ? t('commission_rules.l1_reward') : t('commission_rules.l2_commission') }}
</span>
</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="tierBadgeClass(rule.tier)"
>
{{ tierLabel(rule.tier) }}
</span>
</td>
<td class="px-6 py-4">
<code class="text-sm font-mono text-indigo-300 bg-indigo-500/10 px-2 py-0.5 rounded">{{ rule.region_code }}</code>
</td>
<td class="px-6 py-4">
<template v-if="rule.is_campaign">
<div class="text-xs text-slate-300">
<span class="text-amber-400 font-medium">{{ t('commission_rules.campaign') }}</span>
</div>
<div class="text-xs text-slate-500 mt-0.5">
{{ rule.start_date || '—' }} {{ rule.end_date || '—' }}
</div>
</template>
<span v-else class="text-xs text-slate-500">{{ t('commission_rules.permanent') }}</span>
</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="rule.is_active ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-500/10 text-slate-400'"
>
{{ rule.is_active ? t('commission_rules.active') : t('commission_rules.inactive') }}
</span>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button @click="openEditModal(rule)" class="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition" :title="t('commission_rules.edit')">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="confirmDeactivate(rule)" class="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 transition" :title="t('commission_rules.deactivate')">
<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>
<tr v-if="rules.length === 0">
<td colspan="8" class="px-6 py-12 text-center text-sm text-slate-500">
{{ t('commission_rules.no_items') }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="mt-6 flex items-center justify-between">
<div class="text-sm text-slate-400">
{{ t('commission_rules.showing') }} {{ (currentPage - 1) * pageSize + 1 }}{{ Math.min(currentPage * pageSize, total) }} / {{ total }}
</div>
<div class="flex items-center gap-2">
<button
:disabled="currentPage <= 1"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="currentPage > 1 ? 'bg-slate-700 text-slate-300 hover:bg-slate-600 hover:text-white' : 'bg-slate-800 text-slate-600'"
@click="goToPage(currentPage - 1)"
>
{{ t('commission_rules.prev') }}
</button>
<span class="text-sm text-slate-400 px-2">{{ currentPage }} / {{ totalPages }}</span>
<button
:disabled="currentPage >= totalPages"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="currentPage < totalPages ? 'bg-slate-700 text-slate-300 hover:bg-slate-600 hover:text-white' : 'bg-slate-800 text-slate-600'"
@click="goToPage(currentPage + 1)"
>
{{ t('commission_rules.next') }}
</button>
</div>
</div>
</template>
<!-- Create/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm overflow-y-auto py-8" @click.self="closeModal">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-2xl mx-4 shadow-2xl my-auto">
<h2 class="text-lg font-semibold text-white mb-6">
{{ editingRule ? t('commission_rules.edit_title') : t('commission_rules.create_title') }}
</h2>
<form @submit.prevent="saveRule" class="space-y-5">
<!-- Row: Name + Rule Type -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.name') }} <span class="text-rose-400">*</span></label>
<input
v-model="form.name"
type="text"
required
maxlength="255"
:placeholder="t('commission_rules.name_placeholder')"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
<!-- Rule Type -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.type') }} <span class="text-rose-400">*</span></label>
<select
v-model="form.rule_type"
required
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
>
<option value="L1_REWARD">{{ t('commission_rules.l1_reward') }}</option>
<option value="L2_COMMISSION">{{ t('commission_rules.l2_commission') }}</option>
</select>
</div>
</div>
<!-- Row: Tier + Region -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Tier -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.tier') }}</label>
<select
v-model="form.tier"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
>
<option value="STANDARD">{{ t('commission_rules.tier_standard') }}</option>
<option value="VIP">{{ t('commission_rules.tier_vip') }}</option>
<option value="PLATINUM">{{ t('commission_rules.tier_platinum') }}</option>
<option value="ENTERPRISE">{{ t('commission_rules.tier_enterprise') }}</option>
<option value="CONTRACTED">{{ t('commission_rules.tier_contracted') }}</option>
</select>
</div>
<!-- Region Code -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.region_code_label') }}</label>
<select
v-model="form.region_code"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
>
<option value="GLOBAL">{{ t('commission_rules.region_global') }}</option>
<option value="HU">{{ t('commission_rules.region_hu') }}</option>
<option value="DE">{{ t('commission_rules.region_de') }}</option>
<option value="AT">{{ t('commission_rules.region_at') }}</option>
<option value="RO">{{ t('commission_rules.region_ro') }}</option>
<option value="SK">{{ t('commission_rules.region_sk') }}</option>
</select>
</div>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.description') }}</label>
<textarea
v-model="form.description"
rows="2"
:placeholder="t('commission_rules.desc_placeholder')"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm resize-none"
></textarea>
</div>
<!-- Campaign Section -->
<div class="bg-slate-700/30 rounded-lg p-4 space-y-4">
<div class="flex items-center gap-3">
<input
v-model="form.is_campaign"
type="checkbox"
id="is_campaign"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 text-indigo-500 focus:ring-indigo-500/50"
/>
<label for="is_campaign" class="text-sm text-slate-300 font-medium">{{ t('commission_rules.is_campaign') }}</label>
</div>
<div v-if="form.is_campaign" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Start Date -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.start_date') }}</label>
<input
v-model="form.start_date"
type="date"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
<!-- End Date -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.end_date') }}</label>
<input
v-model="form.end_date"
type="date"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
</div>
</div>
<!-- Rewards Section (L1) -->
<div
class="bg-slate-700/30 rounded-lg p-4 space-y-4 transition-opacity duration-200"
:class="form.rule_type === 'L2_COMMISSION' ? 'opacity-40 pointer-events-none' : ''"
>
<div class="text-sm font-medium text-slate-300 mb-2">{{ t('commission_rules.rewards_section') }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- XP Reward -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.xp_reward') }}</label>
<input
v-model.number="form.xp_reward"
type="number"
min="0"
placeholder="0"
:disabled="form.rule_type === 'L2_COMMISSION'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
/>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.xp_reward_helper') }}</p>
</div>
<!-- Credit Reward -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.credit_reward') }}</label>
<input
v-model.number="form.credit_reward"
type="number"
min="0"
placeholder="0"
:disabled="form.rule_type === 'L2_COMMISSION'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
/>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.credit_reward_helper') }}</p>
</div>
</div>
</div>
<!-- Commission Section (L2) - Százalékos Jutalékok -->
<div
class="bg-slate-700/30 rounded-lg p-4 space-y-4 transition-opacity duration-200"
:class="form.rule_type === 'L1_REWARD' ? 'opacity-40 pointer-events-none' : ''"
>
<div class="text-sm font-medium text-slate-300 mb-2">{{ t('commission_rules.commission_section') }}</div>
<!-- Two-Column Layout: Gen1 (Left) / Gen2 (Right) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Left Column: Gen1 - Közvetlen Ajánló -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-indigo-300 mb-4 border-b border-slate-600/50 pb-2">{{ t('commission_rules.gen1_title') }}</h4>
<!-- Commission Percent (Gen1) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.commission_percent') }}</label>
<div class="relative">
<input
v-model.number="form.commission_percent"
type="number"
min="0"
max="100"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
</div>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.commission_percent_helper') }}</p>
</div>
<!-- Renewal Commission Percent -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.renewal_commission_percent') }}</label>
<div class="relative">
<input
v-model.number="form.renewal_commission_percent"
type="number"
min="0"
max="100"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
</div>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.renewal_commission_percent_helper') }}</p>
</div>
<!-- Gen1 Max Amount (Phase 2) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.gen1_max_amount') }}</label>
<input
v-model.number="form.gen1_max_amount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
/>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.gen1_max_amount_helper') }}</p>
</div>
</div>
<!-- Right Column: Gen2 - Felsővonal -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-emerald-300 mb-4 border-b border-slate-600/50 pb-2">{{ t('commission_rules.gen2_title') }}</h4>
<!-- Upline Commission Percent (Gen2) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.upline_commission_percent') }}</label>
<div class="relative">
<input
v-model.number="form.upline_commission_percent"
type="number"
min="0"
max="100"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
</div>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.upline_commission_percent_helper') }}</p>
</div>
<!-- Gen2 Renewal Percent (Phase 2) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.gen2_renewal_percent') }}</label>
<div class="relative">
<input
v-model.number="form.gen2_renewal_percent"
type="number"
min="0"
max="100"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
</div>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.gen2_renewal_percent_helper') }}</p>
</div>
<!-- Gen2 Max Amount (Phase 2) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.gen2_max_amount') }}</label>
<input
v-model.number="form.gen2_max_amount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
/>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.gen2_max_amount_helper') }}</p>
</div>
</div>
</div>
<!-- Legacy Field: Commission Max Amount (Fallback) - full width below columns -->
<div class="border-t border-slate-600/30 pt-4 mt-2">
<div class="max-w-xs">
<label class="block text-sm font-medium text-slate-400 mb-1">
{{ t('commission_rules.commission_max_amount') }}
<span class="text-xs text-slate-500 ml-1 italic">({{ t('commission_rules.legacy_fallback') }})</span>
</label>
<input
v-model.number="form.commission_max_amount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
/>
<p class="mt-1 text-xs text-slate-500">{{ t('commission_rules.commission_max_amount_helper') }}</p>
</div>
</div>
</div>
<!-- Is Active -->
<div class="flex items-center gap-3">
<input
v-model="form.is_active"
type="checkbox"
id="form_is_active"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 text-indigo-500 focus:ring-indigo-500/50"
/>
<label for="form_is_active" class="text-sm text-slate-300">{{ t('commission_rules.active') }}</label>
</div>
<!-- Error Message -->
<div v-if="formError" class="bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
<p class="text-sm text-rose-400">{{ formError }}</p>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2 border-t border-slate-700">
<button type="button" @click="closeModal" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">{{ t('commission_rules.cancel') }}</button>
<button type="submit" :disabled="saving" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ saving ? t('commission_rules.saving') : t('commission_rules.save') }}
</button>
</div>
</form>
</div>
</div>
<!-- Deactivate Confirmation Modal -->
<div v-if="showDeactivateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeactivateModal = false">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-md mx-4 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-2">{{ t('commission_rules.delete_title') }}</h2>
<p class="text-sm text-slate-400 mb-6">
{{ t('commission_rules.deactivate_confirm', { name: deactivatingRule?.name }) }}
</p>
<div class="flex justify-end gap-3">
<button @click="showDeactivateModal = false" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">{{ t('commission_rules.cancel') }}</button>
<button @click="deactivateRule" :disabled="saving" class="px-4 py-2 bg-rose-600 hover:bg-rose-500 disabled:bg-rose-600/50 text-white rounded-lg text-sm font-medium transition">
{{ saving ? t('commission_rules.saving') : t('commission_rules.deactivate') }}
</button>
</div>
</div>
</div>
<!-- Conflict Override Confirmation Modal -->
<div v-if="showConflictModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showConflictModal = false">
<div class="bg-slate-800 rounded-xl border border-amber-500/30 p-6 w-full max-w-lg mx-4 shadow-2xl">
<div class="flex items-start gap-3 mb-4">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('commission_rules.conflict_title') }}</h2>
<p class="text-sm text-slate-400 mt-1">{{ t('commission_rules.conflict_message') }}</p>
<div v-if="conflictingRule" class="mt-3 bg-slate-700/30 rounded-lg p-3 text-xs text-slate-300 space-y-1">
<div><span class="text-slate-500">ID:</span> {{ conflictingRule.id }}</div>
<div><span class="text-slate-500">{{ t('commission_rules.name') }}:</span> {{ conflictingRule.name }}</div>
<div><span class="text-slate-500">{{ t('commission_rules.tier') }}:</span> {{ conflictingRule.tier }}</div>
<div><span class="text-slate-500">{{ t('commission_rules.region') }}:</span> {{ conflictingRule.region_code }}</div>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-2 border-t border-slate-700">
<button @click="showConflictModal = false; conflictingRule = null" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">{{ t('commission_rules.conflict_cancel') }}</button>
<button @click="confirmOverride" :disabled="saving" class="px-4 py-2 bg-amber-600 hover:bg-amber-500 disabled:bg-amber-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ saving ? t('commission_rules.saving') : t('commission_rules.conflict_confirm') }}
</button>
</div>
</div>
</div>
<!-- Success Toast -->
<div v-if="toast" class="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-up">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ toast }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface CommissionRule {
id: number
rule_type: 'L1_REWARD' | 'L2_COMMISSION'
tier: 'STANDARD' | 'VIP' | 'PLATINUM' | 'ENTERPRISE' | 'CONTRACTED'
region_code: string
xp_reward: number | null
credit_reward: number | null
commission_percent: number | null
upline_commission_percent: number | null
renewal_commission_percent: number | null
commission_max_amount: number | null
gen1_max_amount: number | null
gen2_max_amount: number | null
gen2_renewal_percent: number | null
is_campaign: boolean
start_date: string | null
end_date: string | null
name: string
description: string | null
is_active: boolean
created_by: number | null
created_at: string
updated_at: string
}
interface PaginatedResponse {
items: CommissionRule[]
total: number
page: number
page_size: number
}
const loading = ref(true)
const error = ref(false)
const rules = ref<CommissionRule[]>([])
const showModal = ref(false)
const showDeactivateModal = ref(false)
const showConflictModal = ref(false)
const editingRule = ref<CommissionRule | null>(null)
const deactivatingRule = ref<CommissionRule | null>(null)
const conflictingRule = ref<CommissionRule | null>(null)
const pendingPayload = ref<Record<string, any> | null>(null)
const saving = ref(false)
const formError = ref('')
const toast = ref('')
// Pagination
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
// Filters
const filterType = ref('')
const filterTier = ref('')
const filterRegion = ref('')
const filterActive = ref(false)
const filterCampaign = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function debouncedFetch() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
currentPage.value = 1
fetchRules()
}, 400)
}
const form = reactive({
name: '',
description: '',
rule_type: 'L1_REWARD' as 'L1_REWARD' | 'L2_COMMISSION',
tier: 'STANDARD' as 'STANDARD' | 'VIP' | 'PLATINUM' | 'ENTERPRISE' | 'CONTRACTED',
region_code: 'GLOBAL',
is_campaign: false,
start_date: '',
end_date: '',
xp_reward: null as number | null,
credit_reward: null as number | null,
commission_percent: null as number | null,
upline_commission_percent: null as number | null,
renewal_commission_percent: null as number | null,
commission_max_amount: null as number | null,
gen1_max_amount: null as number | null,
gen2_max_amount: null as number | null,
gen2_renewal_percent: null as number | null,
is_active: true,
})
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }
}
function showToast(msg: string) {
toast.value = msg
setTimeout(() => { toast.value = '' }, 3000)
}
function resetForm() {
form.name = ''
form.description = ''
form.rule_type = 'L1_REWARD'
form.tier = 'STANDARD'
form.region_code = 'GLOBAL'
form.is_campaign = false
form.start_date = ''
form.end_date = ''
form.xp_reward = null
form.credit_reward = null
form.commission_percent = null
form.upline_commission_percent = null
form.renewal_commission_percent = null
form.commission_max_amount = null
form.gen1_max_amount = null
form.gen2_max_amount = null
form.gen2_renewal_percent = null
form.is_active = true
formError.value = ''
}
function openCreateModal() {
editingRule.value = null
resetForm()
showModal.value = true
}
function openEditModal(rule: CommissionRule) {
editingRule.value = rule
form.name = rule.name
form.description = rule.description || ''
form.rule_type = rule.rule_type
form.tier = rule.tier
form.region_code = rule.region_code
form.is_campaign = rule.is_campaign
form.start_date = rule.start_date || ''
form.end_date = rule.end_date || ''
form.xp_reward = rule.xp_reward
form.credit_reward = rule.credit_reward
form.commission_percent = rule.commission_percent
form.upline_commission_percent = rule.upline_commission_percent
form.renewal_commission_percent = rule.renewal_commission_percent
form.commission_max_amount = rule.commission_max_amount
form.gen1_max_amount = rule.gen1_max_amount
form.gen2_max_amount = rule.gen2_max_amount
form.gen2_renewal_percent = rule.gen2_renewal_percent
form.is_active = rule.is_active
formError.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingRule.value = null
resetForm()
}
function confirmDeactivate(rule: CommissionRule) {
deactivatingRule.value = rule
showDeactivateModal.value = true
}
function tierLabel(tier: string): string {
const key = `commission_rules.tier_${tier.toLowerCase()}`
const label = t(key)
return label !== key ? label : tier
}
function tierBadgeClass(tier: string): string {
switch (tier) {
case 'STANDARD': return 'bg-slate-500/10 text-slate-400'
case 'VIP': return 'bg-purple-500/10 text-purple-400'
case 'PLATINUM': return 'bg-cyan-500/10 text-cyan-400'
case 'ENTERPRISE': return 'bg-amber-500/10 text-amber-400'
case 'CONTRACTED': return 'bg-indigo-500/10 text-indigo-400'
default: return 'bg-slate-500/10 text-slate-400'
}
}
function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
fetchRules()
}
async function fetchRules() {
loading.value = true
error.value = false
try {
const params = new URLSearchParams()
params.set('page', String(currentPage.value))
params.set('page_size', String(pageSize.value))
if (filterType.value) params.set('rule_type', filterType.value)
if (filterTier.value) params.set('tier', filterTier.value)
if (filterRegion.value) params.set('region_code', filterRegion.value)
if (filterActive.value) params.set('is_active', 'true')
if (filterCampaign.value) params.set('is_campaign', 'true')
const data = await $fetch<PaginatedResponse>(`/api/v1/admin/commission-rules?${params.toString()}`, {
headers: getHeaders(),
})
rules.value = data.items
total.value = data.total
} catch (e) {
console.error('Failed to fetch commission rules:', e)
error.value = true
} finally {
loading.value = false
}
}
async function saveRule() {
saving.value = true
formError.value = ''
try {
const payload: Record<string, any> = {
name: form.name,
description: form.description || null,
rule_type: form.rule_type,
tier: form.tier,
region_code: form.region_code,
is_campaign: form.is_campaign,
is_active: form.is_active,
}
// Only include campaign dates if is_campaign is true
if (form.is_campaign) {
payload.start_date = form.start_date || null
payload.end_date = form.end_date || null
} else {
payload.start_date = null
payload.end_date = null
}
// Type-specific fields
payload.xp_reward = form.xp_reward
payload.credit_reward = form.credit_reward
payload.commission_percent = form.commission_percent
payload.upline_commission_percent = form.upline_commission_percent
payload.renewal_commission_percent = form.renewal_commission_percent
payload.commission_max_amount = form.commission_max_amount
payload.gen1_max_amount = form.gen1_max_amount
payload.gen2_max_amount = form.gen2_max_amount
payload.gen2_renewal_percent = form.gen2_renewal_percent
if (editingRule.value) {
await $fetch(`/api/v1/admin/commission-rules/${editingRule.value.id}`, {
method: 'PUT',
headers: getHeaders(),
body: payload,
})
showToast(t('commission_rules.updated'))
} else {
await $fetch('/api/v1/admin/commission-rules', {
method: 'POST',
headers: getHeaders(),
body: payload,
})
showToast(t('commission_rules.created'))
}
closeModal()
await fetchRules()
} catch (e: any) {
console.error('Failed to save commission rule:', e)
// Phase 3: Handle 409 Conflict — show override confirmation modal
if (e?.response?.status === 409 || e?.status === 409) {
const detail = e?.data?.detail || e?.response?._data?.detail || {}
const conflicting = detail?.conflicting_rule
if (conflicting) {
conflictingRule.value = conflicting as CommissionRule
pendingPayload.value = {
name: form.name,
description: form.description || null,
rule_type: form.rule_type,
tier: form.tier,
region_code: form.region_code,
is_campaign: form.is_campaign,
is_active: form.is_active,
start_date: form.is_campaign ? (form.start_date || null) : null,
end_date: form.is_campaign ? (form.end_date || null) : null,
xp_reward: form.xp_reward,
credit_reward: form.credit_reward,
commission_percent: form.commission_percent,
upline_commission_percent: form.upline_commission_percent,
renewal_commission_percent: form.renewal_commission_percent,
commission_max_amount: form.commission_max_amount,
gen1_max_amount: form.gen1_max_amount,
gen2_max_amount: form.gen2_max_amount,
gen2_renewal_percent: form.gen2_renewal_percent,
}
showConflictModal.value = true
return // Don't clear form error — modal handles it
}
}
formError.value = e?.data?.detail || e?.message || t('commission_rules.save_error')
} finally {
saving.value = false
}
}
async function confirmOverride() {
if (!pendingPayload.value) return
saving.value = true
formError.value = ''
try {
// Add force_override flag
const payload = { ...pendingPayload.value, force_override: true }
if (editingRule.value) {
await $fetch(`/api/v1/admin/commission-rules/${editingRule.value.id}`, {
method: 'PUT',
headers: getHeaders(),
body: payload,
})
showToast(t('commission_rules.updated'))
} else {
await $fetch('/api/v1/admin/commission-rules', {
method: 'POST',
headers: getHeaders(),
body: payload,
})
showToast(t('commission_rules.created'))
}
showConflictModal.value = false
conflictingRule.value = null
pendingPayload.value = null
closeModal()
await fetchRules()
} catch (e: any) {
console.error('Failed to save commission rule with override:', e)
formError.value = e?.data?.detail || e?.message || t('commission_rules.save_error')
showConflictModal.value = false
conflictingRule.value = null
pendingPayload.value = null
} finally {
saving.value = false
}
}
async function deactivateRule() {
if (!deactivatingRule.value) return
saving.value = true
try {
await $fetch(`/api/v1/admin/commission-rules/${deactivatingRule.value.id}`, {
method: 'DELETE',
headers: getHeaders(),
})
showToast(t('commission_rules.deactivated'))
showDeactivateModal.value = false
deactivatingRule.value = null
await fetchRules()
} catch (e) {
console.error('Failed to deactivate commission rule:', e)
} finally {
saving.value = false
}
}
onMounted(() => {
fetchRules()
})
</script>
<style scoped>
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>