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:
91
app/Http/Controllers/Sales/SalesContractController.php
Normal file
91
app/Http/Controllers/Sales/SalesContractController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user