Files
sam-manage/resources/views/sales/modals/partials/product-selection.blade.php
김보곤 f6876cf481 fix: [sales] Alpine.js promoFreeMonths 미정의 및 closeModal 미정의 오류 수정
- product-selection: Alpine x-data에 promoFreeMonths 속성 추가 및 저장 데이터에 포함
- price-simulator: promoFreeMonths 속성 추가 및 resetPromo()에 초기화 추가
- DemoTenantController: HX-Boosted 제외 조건 제거하여 hx-boost 탐색 시에도 전체 페이지 로드
2026-03-16 14:54:17 +09:00

754 lines
40 KiB
PHP

{{-- 계약 체결 상품 선택 컴포넌트 (가격 조정 기능 포함) --}}
@php
use App\Models\Sales\SalesProductCategory;
use App\Models\Sales\SalesContractProduct;
use App\Models\Sales\SalesTenantManagement;
// 가망고객/테넌트 모드 확인
$isProspect = $isProspect ?? false;
$entity = $entity ?? $tenant ?? null;
$categories = SalesProductCategory::active()
->ordered()
->with(['products' => fn($q) => $q->active()->ordered()])
->get();
// Management ID 조회 (가망고객/테넌트 공통)
if ($isProspect) {
$management = SalesTenantManagement::where('tenant_prospect_id', $entity->id)->first();
} else {
$management = SalesTenantManagement::where('tenant_id', $entity->id)->first();
}
$managementId = $management?->id;
$savedPromotion = $management?->options['promotion'] ?? null;
// 이미 선택된 상품들 조회 (management_id 기반)
$selectedProducts = [];
$contractProducts = collect();
if ($managementId) {
$selectedProducts = SalesContractProduct::where('management_id', $managementId)
->pluck('product_id')
->toArray();
$contractProducts = SalesContractProduct::where('management_id', $managementId)
->get()
->keyBy('product_id');
}
@endphp
<div x-data="productSelection()" class="mt-6 bg-gradient-to-br from-indigo-50 to-blue-50 rounded-xl p-5 border border-indigo-100">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-indigo-100 rounded-lg">
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h3 class="font-bold text-gray-900">SAM 솔루션 상품 선택</h3>
<p class="text-sm text-gray-600">상품을 선택하고 가격을 자유롭게 조정하세요</p>
</div>
</div>
{{-- 개발비-구독료 연동 토글 --}}
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">연동</span>
<button type="button" x-on:click="toggleLinkedPricing()"
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors"
:class="linkedPricing ? 'bg-blue-600' : 'bg-gray-200'">
<span class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow transition"
:class="linkedPricing ? 'translate-x-4' : 'translate-x-0'"></span>
</button>
</div>
</div>
{{-- 연동 안내 --}}
<div x-show="linkedPricing" x-collapse class="mb-4 p-2.5 bg-blue-50 rounded-lg text-xs text-blue-700 flex items-start gap-2">
<svg class="w-4 h-4 mt-0.5 shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span>개발비를 조정하면 구독료가 <strong>비율에 맞춰 자동 연동</strong>됩니다. (최저 구독료 이하로는 내려가지 않습니다)</span>
</div>
{{-- 카테고리 --}}
<div class="flex gap-2 mb-4 overflow-x-auto pb-2">
@foreach($categories as $category)
<button type="button"
x-on:click="activeCategory = '{{ $category->code }}'"
:class="activeCategory === '{{ $category->code }}'
? 'bg-indigo-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100'"
class="px-4 py-2 text-sm font-medium rounded-lg whitespace-nowrap transition-colors">
{{ $category->name }}
<span class="ml-1 text-xs opacity-70"
x-text="'(' + countByCategory('{{ $category->code }}') + ')'"></span>
</button>
@endforeach
</div>
{{-- 상품 목록 --}}
@foreach($categories as $category)
<div x-show="activeCategory === '{{ $category->code }}'" x-cloak>
<div class="space-y-3">
@foreach($category->products as $product)
@php
$contractProduct = $contractProducts->get($product->id);
$savedRegFee = $contractProduct?->registration_fee;
$savedSubFee = $contractProduct?->subscription_fee;
@endphp
<div class="bg-white rounded-lg border transition-all"
:class="isSelected({{ $product->id }}) ? 'border-indigo-300 shadow-sm' : 'border-gray-200'">
<div class="p-4">
<div class="flex items-start gap-3">
{{-- 체크박스 --}}
<button type="button"
x-on:click="toggleProduct({{ $product->id }})"
:disabled="{{ $product->is_required ? 'true' : 'false' }}"
class="flex-shrink-0 mt-0.5 w-5 h-5 rounded border-2 flex items-center justify-center transition-all"
:class="isSelected({{ $product->id }})
? 'bg-indigo-600 border-indigo-600'
: 'border-gray-300 hover:border-indigo-400'">
<svg x-show="isSelected({{ $product->id }})"
class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold text-gray-900">{{ $product->name }}</span>
@if($product->is_required)
<span class="px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-600 rounded">필수</span>
@endif
@if($product->allow_flexible_pricing)
<span class="px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-600 rounded">재량권</span>
@endif
</div>
@if($product->description)
<p class="text-sm text-gray-500 mt-0.5">{{ $product->description }}</p>
@endif
{{-- 가격 정보 --}}
<div class="flex items-center gap-4 mt-2 text-sm">
<span class="text-gray-500">개발비:
<span class="text-gray-400 line-through text-xs">{{ $product->formatted_development_fee }}</span>
<template x-if="isSelected({{ $product->id }}) && getRegFee({{ $product->id }}) != {{ $product->registration_fee }}">
<span>
<span class="text-gray-400 line-through text-xs">{{ number_format($product->registration_fee) }}</span>
<span class="font-bold text-indigo-600" x-text="'₩' + Number(getRegFee({{ $product->id }})).toLocaleString()"></span>
</span>
</template>
<template x-if="!(isSelected({{ $product->id }}) && getRegFee({{ $product->id }}) != {{ $product->registration_fee }})">
<span class="font-semibold text-indigo-600">{{ $product->formatted_registration_fee }}</span>
</template>
</span>
<span class="text-gray-500">구독료:
<template x-if="isSelected({{ $product->id }}) && getSubFee({{ $product->id }}) != {{ $product->subscription_fee }}">
<span>
<span class="text-gray-400 line-through text-xs">{{ number_format($product->subscription_fee) }}</span>
<span class="font-bold text-blue-600" x-text="'₩' + Number(getSubFee({{ $product->id }})).toLocaleString() + '/월'"></span>
</span>
</template>
<template x-if="!(isSelected({{ $product->id }}) && getSubFee({{ $product->id }}) != {{ $product->subscription_fee }})">
<span class="font-semibold text-gray-900">{{ $product->formatted_subscription_fee }}/</span>
</template>
</span>
</div>
{{-- 선택 가격 조정 슬라이더 (재량권 상품만) --}}
@if($product->allow_flexible_pricing)
<template x-if="isSelected({{ $product->id }})">
<div class="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-lg">
<div class="flex items-center justify-between mb-1.5">
<span class="text-xs font-medium text-gray-600">개발비 조정</span>
<span class="text-xs font-bold text-indigo-600"
x-text="'₩' + Number(getRegFee({{ $product->id }})).toLocaleString()"></span>
</div>
<input type="range"
min="{{ $product->min_development_fee ?: 0 }}"
max="{{ $product->registration_fee }}"
step="10000"
:value="getRegFee({{ $product->id }})"
x-on:input="setRegFee({{ $product->id }}, Number($event.target.value))"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600">
<div class="flex justify-between text-xs text-gray-400 mt-1">
@if($product->min_development_fee > 0)
<span class="text-red-500 font-medium">{{ number_format($product->min_development_fee) }} (최저)</span>
@else
<span>₩0</span>
@endif
<span>{{ number_format($product->registration_fee) }} (정가)</span>
</div>
{{-- 연동 구독료 --}}
<template x-if="linkedPricing && getSubFee({{ $product->id }}) != {{ $product->subscription_fee }}">
<div class="mt-2 pt-2 border-t border-blue-200 flex items-center justify-between text-xs">
<span class="text-blue-600 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
연동 구독료
</span>
<span class="font-bold text-blue-700" x-text="'₩' + Number(getSubFee({{ $product->id }})).toLocaleString() + '/월'"></span>
</div>
</template>
@if($product->min_development_fee > 0)
<div class="text-xs text-red-500 mt-1 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
최저 {{ number_format($product->min_development_fee) }} 이하 불가
</div>
@endif
</div>
</template>
@endif
</div>
</div>
</div>
</div>
@endforeach
</div>
</div>
@endforeach
{{-- 영업 재량 할인/프로모션 --}}
<div class="mt-4 bg-white rounded-xl border-2 border-orange-200 overflow-hidden">
<div class="px-4 py-3 border-b-2 border-orange-300 flex items-center justify-between cursor-pointer"
x-on:click="showPromo = !showPromo">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
</svg>
<div>
<h3 class="font-semibold text-orange-700 text-sm">영업 재량 할인/프로모션</h3>
<p class="text-xs text-gray-500">파트너 재량으로 추가 할인 적용</p>
</div>
</div>
<div class="flex items-center gap-2">
<template x-if="hasAnyPromo()">
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-700">적용중</span>
</template>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="showPromo ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div x-show="showPromo" x-collapse class="p-4 space-y-4">
{{-- 개발비 할인 --}}
<div>
<label class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">개발비 추가 할인</span>
<div class="flex items-center gap-2">
<button type="button" x-on:click="promoDevType = 'percent'; promoDevAmount = 0"
class="px-2 py-0.5 text-xs rounded-full transition-colors"
:class="promoDevType === 'percent' ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'">
%
</button>
<button type="button" x-on:click="promoDevType = 'amount'; promoDevAmount = 0"
class="px-2 py-0.5 text-xs rounded-full transition-colors"
:class="promoDevType === 'amount' ? 'bg-orange-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'">
</button>
</div>
</label>
<div class="flex items-center gap-3">
<input type="range" min="0"
:max="promoDevDiscountMax()"
:step="promoDevType === 'percent' ? 5 : 10000"
x-model.number="promoDevAmount"
:disabled="promoDevWaive"
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-orange-500 disabled:opacity-30">
<div class="shrink-0 text-right" style="width: 90px;">
<template x-if="promoDevType === 'percent'">
<span class="text-sm font-semibold text-orange-600" x-text="promoDevAmount + '%'"></span>
</template>
<template x-if="promoDevType === 'amount'">
<span class="text-sm font-semibold text-orange-600" x-text="formatCurrency(promoDevAmount)"></span>
</template>
</div>
</div>
<div class="flex items-center justify-between mt-1.5">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" x-model="promoDevWaive"
x-on:change="if(promoDevWaive) promoDevAmount = 0"
class="rounded border-gray-300 text-orange-600 focus:ring-orange-500">
<span class="text-xs text-gray-600">개발비 전액 면제</span>
</label>
<span class="text-xs text-gray-400" x-show="promoDevWaive">(100% 할인 적용)</span>
</div>
</div>
{{-- 구독료 할인 --}}
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">구독료 할인</span>
<span class="text-sm font-semibold text-orange-600" x-text="promoSubPercent + '%'"></span>
</div>
<input type="range" min="0" :max="promoSubMaxPercent()" step="5"
x-model.number="promoSubPercent"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-emerald-500">
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>0%</span>
<span x-text="promoSubMaxPercent() + '%'"></span>
</div>
</div>
{{-- 무료 기간 --}}
<div>
<span class="text-sm font-medium text-gray-700 mb-2 block">무료 사용 기간</span>
<div class="flex gap-2">
<template x-for="m in [0, 1, 2, 3, 6]" :key="m">
<button type="button" x-on:click="promoFreeMonths = m"
:class="promoFreeMonths === m ? 'bg-violet-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors">
<span x-text="m === 0 ? '없음' : m + '개월'"></span>
</button>
</template>
</div>
<p class="text-xs text-gray-400 mt-1" x-show="promoFreeMonths > 0">
<span x-text="promoFreeMonths"></span>개월 구독료가 면제됩니다
</p>
</div>
{{-- 프로모션 메모 --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로모션 메모 (선택)</label>
<input type="text" x-model="promoNote" placeholder="예: 봄맞이 특별 할인, 신규 고객 프로모션 등"
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-orange-500 focus:border-orange-500">
</div>
{{-- 프로모션 요약 --}}
<div class="bg-orange-50 rounded-lg p-3 text-xs space-y-1" x-show="hasAnyPromo()">
<p class="font-semibold text-orange-700 mb-1">적용 프로모션 요약</p>
<p class="text-gray-600" x-show="promoDevWaive">
<span class="text-orange-500 mr-1">&#10003;</span> 개발비 전액 면제
</p>
<p class="text-gray-600" x-show="!promoDevWaive && promoDevAmount > 0">
<span class="text-orange-500 mr-1">&#10003;</span> 개발비
<span x-text="promoDevType === 'percent' ? promoDevAmount + '% 할인' : formatCurrency(promoDevAmount) + ' 할인'"></span>
</p>
<p class="text-gray-600" x-show="promoSubPercent > 0">
<span class="text-orange-500 mr-1">&#10003;</span> 구독료 <span x-text="promoSubPercent + '% 할인'"></span>
</p>
<p class="text-gray-600" x-show="promoFreeMonths > 0">
<span class="text-orange-500 mr-1">&#10003;</span> 구독료 <span x-text="promoFreeMonths + '개월 무료'"></span>
</p>
</div>
{{-- 초기화 버튼 --}}
<div class="flex justify-end" x-show="hasAnyPromo()">
<button type="button" x-on:click="resetPromo()"
class="text-xs text-gray-500 hover:text-red-600 underline transition-colors">
할인/프로모션 초기화
</button>
</div>
</div>
</div>
{{-- 합계 영역 (전체 카테고리 합산) --}}
<div class="mt-4 pt-4 border-t border-indigo-200">
<div class="bg-white rounded-lg p-4">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-xs text-gray-500 mb-1">선택 상품</p>
<p class="text-xl font-bold text-gray-900" x-text="totalSelectedCount() + '개'"></p>
</div>
<div>
<p class="text-xs text-gray-500 mb-1"> 개발비</p>
<p class="text-xl font-bold text-indigo-600"
:class="promoRegDiscount() > 0 ? 'text-gray-400 line-through text-base' : ''"
x-text="formatCurrency(totalRegFee())"></p>
<template x-if="promoRegDiscount() > 0">
<p class="text-xl font-bold text-indigo-600" x-text="formatCurrency(promoAdjustedRegFee())"></p>
</template>
</div>
<div>
<p class="text-xs text-gray-500 mb-1"> 구독료</p>
<p class="text-xl font-bold text-green-600"
:class="promoSubPercent > 0 ? 'text-gray-400 line-through text-base' : ''"
x-text="formatCurrency(totalSubFee()) + '/월'"></p>
<template x-if="promoSubPercent > 0">
<p class="text-xl font-bold text-green-600" x-text="formatCurrency(promoAdjustedSubFee()) + '/월'"></p>
</template>
</div>
</div>
{{-- 프로모션 할인 금액 --}}
<template x-if="promoRegDiscount() > 0">
<div class="flex justify-between items-center text-xs mt-2 px-2">
<span class="text-orange-600">개발비 할인</span>
<span class="text-orange-600 font-medium" x-text="'-' + formatCurrency(promoRegDiscount())"></span>
</div>
</template>
{{-- 1년차 비용 --}}
<div class="mt-3 pt-3 border-t border-gray-100 space-y-1">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500">
1년차 비용
<template x-if="promoFreeMonths > 0">
<span class="text-orange-500 text-xs">(<span x-text="promoFreeMonths"></span>개월 무료 적용)</span>
</template>
</span>
<span class="text-sm font-bold text-gray-900" x-text="formatCurrency(promoFirstYearTotal())"></span>
</div>
<template x-if="hasAnyPromo()">
<div class="flex justify-between items-center text-xs">
<span class="text-orange-500 font-medium">프로모션 절감액</span>
<span class="text-orange-600 font-bold" x-text="'-' + formatCurrency(promoTotalSaving())"></span>
</div>
</template>
</div>
{{-- 프로모션 메모 --}}
<template x-if="promoNote.length > 0">
<div class="mt-2 bg-orange-50 rounded-lg px-3 py-2 text-xs">
<span class="font-semibold text-orange-700">프로모션:</span>
<span class="text-orange-600" x-text="promoNote"></span>
</div>
</template>
</div>
</div>
{{-- 저장 버튼 --}}
<div class="mt-4 flex justify-end">
<button type="button"
x-on:click="saveSelection()"
:disabled="saving"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
<svg x-show="!saving" 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 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
<svg x-show="saving" class="w-4 h-4 animate-spin" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-text="saving ? '저장 중...' : '상품 선택 저장'"></span>
</button>
</div>
</div>
<script>
function productSelection() {
const productMap = {};
@foreach($categories as $category)
@foreach($category->products as $product)
@php
$cp = $contractProducts->get($product->id);
@endphp
productMap[{{ $product->id }}] = {
categoryId: {{ $product->category_id }},
categoryCode: '{{ $category->code }}',
devFee: {{ $product->development_fee }},
regFee: {{ $product->registration_fee }},
subFee: {{ $product->subscription_fee }},
minDevFee: {{ $product->min_development_fee ?: 0 }},
minSubFee: {{ $product->min_subscription_fee ?: 0 }},
isRequired: {{ $product->is_required ? 'true' : 'false' }},
allowFlexible: {{ $product->allow_flexible_pricing ? 'true' : 'false' }},
savedRegFee: {{ $cp ? $cp->registration_fee : 'null' }},
savedSubFee: {{ $cp ? $cp->subscription_fee : 'null' }},
};
@endforeach
@endforeach
// 카테고리 코드 → ID 매핑
const categoryIdMap = {};
@foreach($categories as $category)
categoryIdMap['{{ $category->code }}'] = {{ $category->id }};
@endforeach
// 초기 선택 목록 빌드
const initialSelected = {};
const initialIds = @json($selectedProducts);
// 필수 상품 포함
Object.keys(productMap).forEach(id => {
const p = productMap[id];
if (p.isRequired && !initialIds.includes(Number(id))) {
initialIds.push(Number(id));
}
});
initialIds.forEach(id => {
const p = productMap[id];
if (!p) return;
initialSelected[id] = {
regFee: p.savedRegFee ?? p.regFee,
subFee: p.savedSubFee ?? p.subFee,
};
});
return {
activeCategory: '{{ $categories->first()?->code ?? '' }}',
selected: initialSelected,
linkedPricing: true,
saving: false,
isProspect: {{ $isProspect ? 'true' : 'false' }},
entityId: {{ $entity->id }},
managementId: {{ $managementId ?? 'null' }},
// 프로모션
showPromo: {{ $savedPromotion ? 'true' : 'false' }},
promoDevType: '{{ $savedPromotion['dev_discount_type'] ?? 'percent' }}',
promoDevAmount: {{ $savedPromotion['dev_discount_amount'] ?? 0 }},
promoDevWaive: {{ !empty($savedPromotion['dev_waive']) ? 'true' : 'false' }},
promoSubPercent: {{ $savedPromotion['sub_discount_percent'] ?? 0 }},
promoFreeTrial: {{ !empty($savedPromotion['free_trial']) ? 'true' : 'false' }},
promoFreeMonths: {{ $savedPromotion['free_months'] ?? 0 }},
promoNote: '{{ addslashes($savedPromotion['note'] ?? '') }}',
isSelected(id) {
return this.selected.hasOwnProperty(id);
},
toggleProduct(id) {
const p = productMap[id];
if (!p || p.isRequired) return;
if (this.isSelected(id)) {
const copy = { ...this.selected };
delete copy[id];
this.selected = copy;
} else {
this.selected = {
...this.selected,
[id]: { regFee: p.regFee, subFee: p.subFee }
};
}
this.$nextTick(() => this.clampPromoValues());
},
getRegFee(id) {
return this.selected[id]?.regFee ?? productMap[id]?.regFee ?? 0;
},
getSubFee(id) {
return this.selected[id]?.subFee ?? productMap[id]?.subFee ?? 0;
},
setRegFee(id, value) {
if (!this.selected[id]) return;
const p = productMap[id];
const clamped = Math.max(p.minDevFee, value);
const update = { ...this.selected[id], regFee: clamped };
// 연동 모드: 구독료 비율 유지
if (this.linkedPricing && p) {
const baseReg = p.regFee || 1;
const ratio = p.subFee / baseReg;
const linkedSub = Math.round(clamped * ratio / 10000) * 10000;
update.subFee = Math.max(p.minSubFee, linkedSub);
}
this.selected = { ...this.selected, [id]: update };
this.$nextTick(() => this.clampPromoValues());
},
toggleLinkedPricing() {
this.linkedPricing = !this.linkedPricing;
if (this.linkedPricing) {
// 연동 ON: 현재 regFee 기준으로 subFee 재계산
const updated = { ...this.selected };
Object.keys(updated).forEach(id => {
const p = productMap[id];
if (!p) return;
const baseReg = p.regFee || 1;
const ratio = p.subFee / baseReg;
const linkedSub = Math.round(updated[id].regFee * ratio / 10000) * 10000;
updated[id] = { ...updated[id], subFee: Math.max(p.minSubFee, linkedSub) };
});
this.selected = updated;
} else {
// 연동 OFF: 원래 구독료 복원
const updated = { ...this.selected };
Object.keys(updated).forEach(id => {
const p = productMap[id];
if (!p) return;
updated[id] = { ...updated[id], subFee: p.savedSubFee ?? p.subFee };
});
this.selected = updated;
}
},
countByCategory(code) {
return Object.keys(this.selected).filter(id => productMap[id]?.categoryCode === code).length;
},
totalSelectedCount() {
return Object.keys(this.selected).length;
},
totalRegFee() {
return Object.keys(this.selected).reduce((sum, id) => sum + (this.selected[id]?.regFee || 0), 0);
},
totalSubFee() {
return Object.keys(this.selected).reduce((sum, id) => sum + (this.selected[id]?.subFee || 0), 0);
},
formatCurrency(value) {
return '₩' + Number(value).toLocaleString();
},
// --- 프로모션 계산 ---
hasAnyPromo() {
return this.promoDevWaive || this.promoDevAmount > 0 || this.promoSubPercent > 0 || this.promoFreeMonths > 0;
},
promoDevDiscountMax() {
const totalReg = this.totalRegFee();
// 최저 개발비 합산 (min_development_fee가 있는 상품들의 최저가 합산)
let totalMin = 0;
Object.keys(this.selected).forEach(id => {
const p = productMap[id];
if (p) totalMin += p.minDevFee;
});
const maxDiscount = totalReg - totalMin;
if (this.promoDevType === 'percent') {
if (totalReg <= 0) return 0;
return Math.min(50, Math.floor(maxDiscount / totalReg * 100 / 5) * 5);
}
return Math.max(0, maxDiscount);
},
promoSubMaxPercent() {
// 최저 구독료 기준으로 최대 할인율 계산
const totalSub = this.totalSubFee();
if (totalSub <= 0) return 0;
let totalMinSub = 0;
Object.keys(this.selected).forEach(id => {
const p = productMap[id];
if (p) totalMinSub += p.minSubFee;
});
const maxPercent = Math.floor((1 - totalMinSub / totalSub) * 100 / 5) * 5;
return Math.min(50, Math.max(0, maxPercent));
},
promoRegDiscount() {
const totalReg = this.totalRegFee();
if (this.promoDevWaive) {
let totalMin = 0;
Object.keys(this.selected).forEach(id => {
const p = productMap[id];
if (p) totalMin += p.minDevFee;
});
return Math.max(0, totalReg - totalMin);
}
if (this.promoDevAmount <= 0) return 0;
if (this.promoDevType === 'percent') {
return Math.floor(totalReg * this.promoDevAmount / 100);
}
return Math.min(this.promoDevAmount, totalReg);
},
promoAdjustedRegFee() {
return Math.max(0, this.totalRegFee() - this.promoRegDiscount());
},
promoAdjustedSubFee() {
const base = this.totalSubFee();
if (this.promoSubPercent <= 0) return base;
return Math.floor(base * (100 - this.promoSubPercent) / 100);
},
promoFirstYearTotal() {
const regFee = this.promoAdjustedRegFee();
const subFee = this.promoAdjustedSubFee();
const paidMonths = 12 - this.promoFreeMonths;
return regFee + subFee * paidMonths;
},
promoTotalSaving() {
const originalTotal = this.totalRegFee() + this.totalSubFee() * 12;
return originalTotal - this.promoFirstYearTotal();
},
resetPromo() {
this.promoDevType = 'percent';
this.promoDevAmount = 0;
this.promoDevWaive = false;
this.promoSubPercent = 0;
this.promoFreeMonths = 0;
this.promoNote = '';
},
clampPromoValues() {
const devMax = this.promoDevDiscountMax();
if (this.promoDevAmount > devMax) this.promoDevAmount = devMax;
const subMax = this.promoSubMaxPercent();
if (this.promoSubPercent > subMax) this.promoSubPercent = subMax;
},
async saveSelection() {
this.saving = true;
try {
// 모든 카테고리의 선택된 상품 저장 (카테고리별로 그룹핑)
const byCat = {};
Object.keys(this.selected).forEach(id => {
const p = productMap[id];
if (!p) return;
if (!byCat[p.categoryId]) byCat[p.categoryId] = [];
byCat[p.categoryId].push({
product_id: Number(id),
category_id: p.categoryId,
registration_fee: this.selected[id].regFee,
subscription_fee: this.selected[id].subFee,
});
});
// 카테고리별로 순차 저장
for (const [catId, products] of Object.entries(byCat)) {
const requestData = {
category_id: Number(catId),
products: products,
};
if (this.isProspect) {
requestData.prospect_id = this.entityId;
} else {
requestData.tenant_id = this.entityId;
}
requestData.management_id = this.managementId;
// 프로모션 정보 포함
if (this.hasAnyPromo()) {
requestData.promotion = {
dev_discount_type: this.promoDevType,
dev_discount_amount: this.promoDevAmount,
dev_waive: this.promoDevWaive,
sub_discount_percent: this.promoSubPercent,
free_trial: this.promoFreeTrial,
free_months: this.promoFreeMonths,
note: this.promoNote,
};
}
const response = await fetch('/sales/contracts/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
body: JSON.stringify(requestData),
});
const result = await response.json();
if (!result.success) {
alert(result.message || '저장에 실패했습니다.');
return;
}
if (result.management_id) {
this.managementId = result.management_id;
}
}
alert('상품 선택이 저장되었습니다.');
} catch (error) {
console.error('저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
this.saving = false;
}
}
};
}
</script>