feat:계약 체결 단계에 상품 선택 기능 추가

- SalesContractController: 계약 상품 저장/조회 API
- product-selection.blade.php: 상품 선택 UI 컴포넌트
- scenario-step.blade.php: 6단계에서 상품 선택 컴포넌트 표시
- routes/web.php: /sales/contracts/* 라우트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-29 15:04:26 +09:00
parent f48d4b036a
commit 7547a63284
4 changed files with 339 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Sales\SalesContractProduct;
use App\Models\Sales\SalesTenantManagement;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 영업 계약관리 컨트롤러
*/
class SalesContractController extends Controller
{
/**
* 계약 상품 저장 (전체 교체 방식)
*/
public function saveProducts(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|exists:tenants,id',
'products' => 'required|array',
'products.*.product_id' => 'required|exists:sales_products,id',
'products.*.category_id' => 'required|exists:sales_product_categories,id',
'products.*.development_fee' => 'required|numeric|min:0',
'products.*.subscription_fee' => 'required|numeric|min:0',
]);
try {
DB::transaction(function () use ($validated) {
$tenantId = $validated['tenant_id'];
// 영업관리 레코드 조회 (없으면 생성)
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
// 기존 상품 삭제
SalesContractProduct::where('tenant_id', $tenantId)->delete();
// 새 상품 저장
foreach ($validated['products'] as $product) {
SalesContractProduct::create([
'tenant_id' => $tenantId,
'management_id' => $management->id,
'category_id' => $product['category_id'],
'product_id' => $product['product_id'],
'development_fee' => $product['development_fee'],
'subscription_fee' => $product['subscription_fee'],
'discount_rate' => 0,
'created_by' => auth()->id(),
]);
}
});
return response()->json([
'success' => true,
'message' => '계약 상품이 저장되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '저장 중 오류가 발생했습니다.',
], 500);
}
}
/**
* 계약 상품 조회
*/
public function getProducts(int $tenantId): JsonResponse
{
$products = SalesContractProduct::where('tenant_id', $tenantId)
->with(['product', 'category'])
->get();
$totals = [
'development_fee' => $products->sum('development_fee'),
'subscription_fee' => $products->sum('subscription_fee'),
'count' => $products->count(),
];
return response()->json([
'success' => true,
'data' => [
'products' => $products,
'totals' => $totals,
],
]);
}
}

View File

@@ -0,0 +1,237 @@
{{-- 계약 체결 상품 선택 컴포넌트 --}}
@php
use App\Models\Sales\SalesProductCategory;
use App\Models\Sales\SalesContractProduct;
$categories = SalesProductCategory::active()
->ordered()
->with(['products' => fn($q) => $q->active()->ordered()])
->get();
// 이미 선택된 상품들 조회
$selectedProducts = SalesContractProduct::where('tenant_id', $tenant->id)
->pluck('product_id')
->toArray();
// 기존 계약 상품 정보 (가격 커스터마이징 포함)
$contractProducts = SalesContractProduct::where('tenant_id', $tenant->id)
->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="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 }}
</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->development_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="font-semibold text-indigo-600">{{ $product->formatted_development_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="selectedProducts.length + '개'"></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: {},
totalDevFee: 0,
totalSubFee: 0,
saving: false,
init() {
// 초기 데이터 설정
@foreach($categories as $category)
@foreach($category->products as $product)
this.productData[{{ $product->id }}] = {
categoryId: {{ $product->category_id }},
devFee: {{ $product->development_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();
},
toggleProduct(productId, categoryId, devFee, 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.selectedProducts.forEach(id => {
const product = this.productData[id];
if (product) {
this.totalDevFee += product.devFee;
this.totalSubFee += product.subFee;
}
});
},
formatCurrency(value) {
return '₩' + Number(value).toLocaleString();
},
async saveSelection() {
this.saving = true;
try {
const products = this.selectedProducts.map(id => ({
product_id: id,
category_id: this.productData[id].categoryId,
development_fee: this.productData[id].devFee,
subscription_fee: this.productData[id].subFee,
}));
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({
tenant_id: {{ $tenant->id }},
products: products,
}),
});
const result = await response.json();
if (result.success) {
// 성공 메시지 표시 (간단히 alert 사용)
alert('상품 선택이 저장되었습니다.');
} else {
alert(result.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
this.saving = false;
}
}
};
}
</script>

View File

@@ -149,6 +149,11 @@ class="border-t border-gray-100">
@endforeach
</div>
{{-- 계약 체결 단계 (Step 6)에서만 상품 선택 컴포넌트 표시 --}}
@if($step['id'] === 6 && $scenarioType === 'sales')
@include('sales.modals.partials.product-selection', ['tenant' => $tenant])
@endif
{{-- 단계 이동 버튼 --}}
@php
$currentStepId = (int) $step['id'];

View File

@@ -842,4 +842,10 @@
// API (영업 시나리오용)
Route::get('/api/list', [SalesProductController::class, 'getProductsApi'])->name('api.list');
});
// 계약관리
Route::prefix('contracts')->name('contracts.')->group(function () {
Route::post('/products', [\App\Http\Controllers\Sales\SalesContractController::class, 'saveProducts'])->name('products.save');
Route::get('/products/{tenant}', [\App\Http\Controllers\Sales\SalesContractController::class, 'getProducts'])->name('products.get');
});
});