영업 관련 코드 및 문서 전체에서 "가입비"를 "개발비"로 변경 - 컨트롤러, 서비스, 모델 - 뷰 템플릿 (blade 파일) - 가이드북 문서 (마크다운) - 설정 파일 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
294 lines
14 KiB
PHP
294 lines
14 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;
|
|
|
|
// 이미 선택된 상품들 조회 (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 gap-3 mb-4">
|
|
<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 gap-2 mb-4 overflow-x-auto pb-2">
|
|
@foreach($categories as $category)
|
|
<button type="button"
|
|
x-on:click="switchCategory('{{ $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 }}
|
|
</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
|
|
$isSelected = in_array($product->id, $selectedProducts);
|
|
$contractProduct = $contractProducts->get($product->id);
|
|
$devFee = $contractProduct?->development_fee ?? $product->development_fee;
|
|
$subFee = $contractProduct?->subscription_fee ?? $product->subscription_fee;
|
|
@endphp
|
|
<div class="bg-white rounded-lg border transition-all"
|
|
:class="selectedProducts.includes({{ $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 }}, {{ $product->category_id }}, {{ $product->registration_fee }}, {{ $product->subscription_fee }}, {{ $product->is_required ? 'true' : 'false' }})"
|
|
: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="selectedProducts.includes({{ $product->id }})
|
|
? 'bg-indigo-600 border-indigo-600'
|
|
: 'border-gray-300 hover:border-indigo-400'">
|
|
<svg x-show="selectedProducts.includes({{ $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>
|
|
<span class="font-semibold text-indigo-600">{{ $product->formatted_registration_fee }}</span>
|
|
</span>
|
|
<span class="text-gray-500">월 구독료: <span class="font-semibold text-gray-900">{{ $product->formatted_subscription_fee }}</span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
|
|
{{-- 합계 영역 --}}
|
|
<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="selectedCount + '개'"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-gray-500 mb-1">총 개발비</p>
|
|
<p class="text-xl font-bold text-indigo-600" x-text="formatCurrency(totalDevFee)"></p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-gray-500 mb-1">월 구독료</p>
|
|
<p class="text-xl font-bold text-green-600" x-text="formatCurrency(totalSubFee)"></p>
|
|
</div>
|
|
</div>
|
|
</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() {
|
|
return {
|
|
activeCategory: '{{ $categories->first()?->code ?? '' }}',
|
|
selectedProducts: @json($selectedProducts),
|
|
productData: {},
|
|
categoryMap: {},
|
|
totalDevFee: 0,
|
|
totalSubFee: 0,
|
|
selectedCount: 0,
|
|
saving: false,
|
|
isProspect: {{ $isProspect ? 'true' : 'false' }},
|
|
entityId: {{ $entity->id }},
|
|
managementId: {{ $managementId ?? 'null' }},
|
|
|
|
init() {
|
|
// 카테고리 코드 → ID 매핑
|
|
@foreach($categories as $category)
|
|
this.categoryMap['{{ $category->code }}'] = {{ $category->id }};
|
|
@endforeach
|
|
|
|
// 초기 데이터 설정
|
|
@foreach($categories as $category)
|
|
@foreach($category->products as $product)
|
|
this.productData[{{ $product->id }}] = {
|
|
categoryId: {{ $product->category_id }},
|
|
categoryCode: '{{ $category->code }}',
|
|
regFee: {{ $product->registration_fee }},
|
|
subFee: {{ $product->subscription_fee }},
|
|
isRequired: {{ $product->is_required ? 'true' : 'false' }},
|
|
};
|
|
@if($product->is_required && !in_array($product->id, $selectedProducts))
|
|
this.selectedProducts.push({{ $product->id }});
|
|
@endif
|
|
@endforeach
|
|
@endforeach
|
|
this.calculateTotals();
|
|
},
|
|
|
|
switchCategory(code) {
|
|
this.activeCategory = code;
|
|
this.calculateTotals();
|
|
},
|
|
|
|
toggleProduct(productId, categoryId, regFee, subFee, isRequired) {
|
|
if (isRequired) return; // 필수 상품은 토글 불가
|
|
|
|
const index = this.selectedProducts.indexOf(productId);
|
|
if (index > -1) {
|
|
this.selectedProducts.splice(index, 1);
|
|
} else {
|
|
this.selectedProducts.push(productId);
|
|
}
|
|
this.calculateTotals();
|
|
},
|
|
|
|
calculateTotals() {
|
|
this.totalDevFee = 0;
|
|
this.totalSubFee = 0;
|
|
this.selectedCount = 0;
|
|
|
|
// 현재 활성 카테고리의 상품만 합계 계산
|
|
this.selectedProducts.forEach(id => {
|
|
const product = this.productData[id];
|
|
if (product && product.categoryCode === this.activeCategory) {
|
|
this.totalDevFee += product.regFee;
|
|
this.totalSubFee += product.subFee;
|
|
this.selectedCount++;
|
|
}
|
|
});
|
|
},
|
|
|
|
formatCurrency(value) {
|
|
return '₩' + Number(value).toLocaleString();
|
|
},
|
|
|
|
async saveSelection() {
|
|
this.saving = true;
|
|
try {
|
|
// 현재 카테고리의 선택된 상품만 저장
|
|
const currentCategoryId = this.categoryMap[this.activeCategory];
|
|
const products = this.selectedProducts
|
|
.filter(id => this.productData[id].categoryCode === this.activeCategory)
|
|
.map(id => ({
|
|
product_id: id,
|
|
category_id: this.productData[id].categoryId,
|
|
registration_fee: this.productData[id].regFee,
|
|
subscription_fee: this.productData[id].subFee,
|
|
}));
|
|
|
|
// 요청 데이터 구성 (가망고객/테넌트 모드에 따라)
|
|
const requestData = {
|
|
category_id: currentCategoryId,
|
|
products: products,
|
|
};
|
|
|
|
if (this.isProspect) {
|
|
requestData.prospect_id = this.entityId;
|
|
requestData.management_id = this.managementId;
|
|
} else {
|
|
requestData.tenant_id = this.entityId;
|
|
requestData.management_id = this.managementId;
|
|
}
|
|
|
|
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('상품 선택이 저장되었습니다.');
|
|
// management_id 업데이트 (새로 생성된 경우)
|
|
if (result.management_id) {
|
|
this.managementId = result.management_id;
|
|
}
|
|
} else {
|
|
alert(result.message || '저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('저장 실패:', error);
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
</script>
|