Files
sam-manage/resources/views/sales/modals/partials/product-selection.blade.php
김보곤 12c6175470 refactor:용어 변경 - 가입비 → 개발비
영업 관련 코드 및 문서 전체에서 "가입비"를 "개발비"로 변경
- 컨트롤러, 서비스, 모델
- 뷰 템플릿 (blade 파일)
- 가이드북 문서 (마크다운)
- 설정 파일

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:20:09 +09:00

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>