- product-selection: Alpine x-data에 promoFreeMonths 속성 추가 및 저장 데이터에 포함 - price-simulator: promoFreeMonths 속성 추가 및 resetPromo()에 초기화 추가 - DemoTenantController: HX-Boosted 제외 조건 제거하여 hx-boost 탐색 시에도 전체 페이지 로드
754 lines
40 KiB
PHP
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">✓</span> 개발비 전액 면제
|
|
</p>
|
|
<p class="text-gray-600" x-show="!promoDevWaive && promoDevAmount > 0">
|
|
<span class="text-orange-500 mr-1">✓</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">✓</span> 구독료 <span x-text="promoSubPercent + '% 할인'"></span>
|
|
</p>
|
|
<p class="text-gray-600" x-show="promoFreeMonths > 0">
|
|
<span class="text-orange-500 mr-1">✓</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>
|