diff --git a/app/Http/Controllers/Sales/SalesProductController.php b/app/Http/Controllers/Sales/SalesProductController.php new file mode 100644 index 00000000..f24baa66 --- /dev/null +++ b/app/Http/Controllers/Sales/SalesProductController.php @@ -0,0 +1,268 @@ +ordered() + ->with(['products' => fn($q) => $q->ordered()]) + ->get(); + + $currentCategoryCode = $request->input('category', $categories->first()?->code); + $currentCategory = $categories->firstWhere('code', $currentCategoryCode) ?? $categories->first(); + + return view('sales.products.index', compact('categories', 'currentCategory')); + } + + /** + * 상품 목록 (HTMX용) + */ + public function productList(Request $request): View + { + $categoryCode = $request->input('category'); + $category = SalesProductCategory::where('code', $categoryCode) + ->with(['products' => fn($q) => $q->ordered()]) + ->first(); + + return view('sales.products.partials.product-list', compact('category')); + } + + /** + * 상품 저장 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'category_id' => 'required|exists:sales_product_categories,id', + 'code' => 'required|string|max:50', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + 'development_fee' => 'required|numeric|min:0', + 'subscription_fee' => 'required|numeric|min:0', + 'commission_rate' => 'nullable|numeric|min:0|max:100', + 'allow_flexible_pricing' => 'boolean', + 'is_required' => 'boolean', + ]); + + // 코드 중복 체크 + $exists = SalesProduct::where('category_id', $validated['category_id']) + ->where('code', $validated['code']) + ->exists(); + + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => '이미 존재하는 상품 코드입니다.', + ], 422); + } + + // 순서 설정 (마지막) + $maxOrder = SalesProduct::where('category_id', $validated['category_id'])->max('display_order') ?? 0; + $validated['display_order'] = $maxOrder + 1; + $validated['commission_rate'] = $validated['commission_rate'] ?? 25.00; + + $product = SalesProduct::create($validated); + + return response()->json([ + 'success' => true, + 'message' => '상품이 등록되었습니다.', + 'product' => $product, + ]); + } + + /** + * 상품 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $product = SalesProduct::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:100', + 'description' => 'nullable|string', + 'development_fee' => 'sometimes|numeric|min:0', + 'subscription_fee' => 'sometimes|numeric|min:0', + 'commission_rate' => 'nullable|numeric|min:0|max:100', + 'allow_flexible_pricing' => 'boolean', + 'is_required' => 'boolean', + 'is_active' => 'boolean', + ]); + + $product->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '상품이 수정되었습니다.', + 'product' => $product->fresh(), + ]); + } + + /** + * 상품 삭제 (soft delete) + */ + public function destroy(int $id): JsonResponse + { + $product = SalesProduct::findOrFail($id); + $product->delete(); + + return response()->json([ + 'success' => true, + 'message' => '상품이 삭제되었습니다.', + ]); + } + + /** + * 활성화 토글 + */ + public function toggleActive(int $id): JsonResponse + { + $product = SalesProduct::findOrFail($id); + $product->update(['is_active' => !$product->is_active]); + + return response()->json([ + 'success' => true, + 'message' => $product->is_active ? '상품이 활성화되었습니다.' : '상품이 비활성화되었습니다.', + 'is_active' => $product->is_active, + ]); + } + + /** + * 순서 변경 + */ + public function reorder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'orders' => 'required|array', + 'orders.*.id' => 'required|exists:sales_products,id', + 'orders.*.order' => 'required|integer|min:0', + ]); + + foreach ($validated['orders'] as $item) { + SalesProduct::where('id', $item['id'])->update(['display_order' => $item['order']]); + } + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } + + // ==================== 카테고리 관리 ==================== + + /** + * 카테고리 목록 + */ + public function categories(): JsonResponse + { + $categories = SalesProductCategory::ordered()->get(); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } + + /** + * 카테고리 생성 + */ + public function storeCategory(Request $request): JsonResponse + { + $validated = $request->validate([ + 'code' => 'required|string|max:50|unique:sales_product_categories,code', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + 'base_storage' => 'nullable|string|max:20', + ]); + + $maxOrder = SalesProductCategory::max('display_order') ?? 0; + $validated['display_order'] = $maxOrder + 1; + + $category = SalesProductCategory::create($validated); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 생성되었습니다.', + 'category' => $category, + ]); + } + + /** + * 카테고리 수정 + */ + public function updateCategory(Request $request, int $id): JsonResponse + { + $category = SalesProductCategory::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:100', + 'description' => 'nullable|string', + 'base_storage' => 'nullable|string|max:20', + 'is_active' => 'boolean', + ]); + + $category->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 수정되었습니다.', + 'category' => $category->fresh(), + ]); + } + + /** + * 카테고리 삭제 + */ + public function deleteCategory(int $id): JsonResponse + { + $category = SalesProductCategory::findOrFail($id); + + // 상품이 있으면 삭제 불가 + if ($category->products()->exists()) { + return response()->json([ + 'success' => false, + 'message' => '상품이 있는 카테고리는 삭제할 수 없습니다.', + ], 422); + } + + $category->delete(); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 삭제되었습니다.', + ]); + } + + // ==================== API (영업 시나리오용) ==================== + + /** + * 상품 목록 API (카테고리 포함) + */ + public function getProductsApi(): JsonResponse + { + $categories = SalesProductCategory::active() + ->ordered() + ->with(['products' => fn($q) => $q->active()->ordered()]) + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } +} diff --git a/app/Models/Sales/SalesContractProduct.php b/app/Models/Sales/SalesContractProduct.php new file mode 100644 index 00000000..1158c34e --- /dev/null +++ b/app/Models/Sales/SalesContractProduct.php @@ -0,0 +1,106 @@ + 'integer', + 'management_id' => 'integer', + 'category_id' => 'integer', + 'product_id' => 'integer', + 'development_fee' => 'decimal:2', + 'subscription_fee' => 'decimal:2', + 'discount_rate' => 'decimal:2', + 'created_by' => 'integer', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 영업관리 관계 + */ + public function management(): BelongsTo + { + return $this->belongsTo(SalesTenantManagement::class, 'management_id'); + } + + /** + * 카테고리 관계 + */ + public function category(): BelongsTo + { + return $this->belongsTo(SalesProductCategory::class, 'category_id'); + } + + /** + * 상품 관계 + */ + public function product(): BelongsTo + { + return $this->belongsTo(SalesProduct::class, 'product_id'); + } + + /** + * 등록자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 테넌트별 총 개발비 + */ + public static function getTotalDevelopmentFee(int $tenantId): float + { + return self::where('tenant_id', $tenantId)->sum('development_fee') ?? 0; + } + + /** + * 테넌트별 총 구독료 + */ + public static function getTotalSubscriptionFee(int $tenantId): float + { + return self::where('tenant_id', $tenantId)->sum('subscription_fee') ?? 0; + } +} diff --git a/app/Models/Sales/SalesProduct.php b/app/Models/Sales/SalesProduct.php new file mode 100644 index 00000000..5c6856d2 --- /dev/null +++ b/app/Models/Sales/SalesProduct.php @@ -0,0 +1,103 @@ + 'integer', + 'development_fee' => 'decimal:2', + 'subscription_fee' => 'decimal:2', + 'commission_rate' => 'decimal:2', + 'allow_flexible_pricing' => 'boolean', + 'is_required' => 'boolean', + 'display_order' => 'integer', + 'is_active' => 'boolean', + ]; + + /** + * 카테고리 관계 + */ + public function category(): BelongsTo + { + return $this->belongsTo(SalesProductCategory::class, 'category_id'); + } + + /** + * 활성 상품 스코프 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 정렬 스코프 + */ + public function scopeOrdered($query) + { + return $query->orderBy('display_order')->orderBy('name'); + } + + /** + * 수당 계산 (개발비 기준) + */ + public function getCommissionAttribute(): float + { + return $this->development_fee * ($this->commission_rate / 100); + } + + /** + * 포맷된 개발비 + */ + public function getFormattedDevelopmentFeeAttribute(): string + { + return '₩' . number_format($this->development_fee); + } + + /** + * 포맷된 구독료 + */ + public function getFormattedSubscriptionFeeAttribute(): string + { + return '₩' . number_format($this->subscription_fee); + } +} diff --git a/app/Models/Sales/SalesProductCategory.php b/app/Models/Sales/SalesProductCategory.php new file mode 100644 index 00000000..ba8cd2ce --- /dev/null +++ b/app/Models/Sales/SalesProductCategory.php @@ -0,0 +1,71 @@ + 'integer', + 'is_active' => 'boolean', + ]; + + /** + * 상품 관계 + */ + public function products(): HasMany + { + return $this->hasMany(SalesProduct::class, 'category_id'); + } + + /** + * 활성 상품만 + */ + public function activeProducts(): HasMany + { + return $this->products()->where('is_active', true)->orderBy('display_order'); + } + + /** + * 활성 카테고리 스코프 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 정렬 스코프 + */ + public function scopeOrdered($query) + { + return $query->orderBy('display_order')->orderBy('name'); + } +} diff --git a/resources/views/sales/products/index.blade.php b/resources/views/sales/products/index.blade.php new file mode 100644 index 00000000..3c736dc0 --- /dev/null +++ b/resources/views/sales/products/index.blade.php @@ -0,0 +1,372 @@ +@extends('layouts.app') + +@section('title', '상품관리') + +@section('content') +
+ {{-- 헤더 --}} +
+
+
+ + + +
+
+

상품관리

+

SAM 솔루션 상품 및 요금 설정

+
+
+ +
+ + {{-- 카테고리 탭 --}} +
+
+ +
+ + {{-- 상품 목록 영역 --}} +
+ {{-- 헤더 --}} +
+
+ + (기본 제공: ) +
+ +
+ + {{-- 상품 카드 그리드 --}} +
+ @include('sales.products.partials.product-list', ['category' => $currentCategory]) +
+
+
+ + {{-- 상품 추가/수정 모달 --}} +
+
+
+
+

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + {{-- 카테고리 관리 모달 --}} +
+
+
+
+

카테고리 관리

+
+ @foreach($categories as $category) +
+
+
{{ $category->name }}
+
{{ $category->code }} / {{ $category->base_storage }}
+
+
+ {{ $category->products->count() }}개 상품 +
+
+ @endforeach +
+
+

새 카테고리 추가

+
+ + + +
+
+ +
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/sales/products/partials/product-list.blade.php b/resources/views/sales/products/partials/product-list.blade.php new file mode 100644 index 00000000..8432ef0b --- /dev/null +++ b/resources/views/sales/products/partials/product-list.blade.php @@ -0,0 +1,77 @@ +@if($category && $category->products->count() > 0) + @foreach($category->products as $product) +
+ {{-- 헤더 --}} +
+
+
+

{{ $product->name }}

+ @if($product->is_required) + 필수 + @endif + @if(!$product->is_active) + 비활성 + @endif +
+

{{ $product->code }}

+
+ +
+ + {{-- 설명 --}} + @if($product->description) +

{{ $product->description }}

+ @endif + + {{-- 가격 정보 --}} +
+
+ 가입비 + {{ $product->formatted_development_fee }} +
+
+ 월 구독료 + {{ $product->formatted_subscription_fee }} +
+
+ 수당 ({{ number_format($product->commission_rate, 0) }}%) + ₩{{ number_format($product->commission) }} +
+
+ + {{-- 하단 태그 --}} +
+
+ @if($product->allow_flexible_pricing) + 재량권 허용 + @else + 고정가 + @endif +
+ +
+
+ @endforeach +@else +
+
+ + + +
+

등록된 상품이 없습니다

+

상품 추가 버튼을 클릭하여 새 상품을 등록하세요

+
+@endif diff --git a/routes/web.php b/routes/web.php index 57f587ed..9d496683 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,6 +23,7 @@ use App\Http\Controllers\QuoteFormulaController; use App\Http\Controllers\RoleController; use App\Http\Controllers\RolePermissionController; +use App\Http\Controllers\Sales\SalesProductController; use App\Http\Controllers\System\AiConfigController; use App\Http\Controllers\TenantController; use App\Http\Controllers\TenantSettingController; @@ -819,4 +820,26 @@ // 매니저 목록 조회 (드롭다운용) Route::get('/managers/list', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'getManagers'])->name('managers.list'); + + // 상품관리 (HQ 전용) + Route::prefix('products')->name('products.')->group(function () { + Route::get('/', [SalesProductController::class, 'index'])->name('index'); + Route::get('/list', [SalesProductController::class, 'productList'])->name('list'); + Route::post('/', [SalesProductController::class, 'store'])->name('store'); + Route::put('/{id}', [SalesProductController::class, 'update'])->name('update'); + Route::delete('/{id}', [SalesProductController::class, 'destroy'])->name('destroy'); + Route::post('/{id}/toggle', [SalesProductController::class, 'toggleActive'])->name('toggle'); + Route::post('/reorder', [SalesProductController::class, 'reorder'])->name('reorder'); + + // 카테고리 관리 + Route::prefix('categories')->name('categories.')->group(function () { + Route::get('/', [SalesProductController::class, 'categories'])->name('index'); + Route::post('/', [SalesProductController::class, 'storeCategory'])->name('store'); + Route::put('/{id}', [SalesProductController::class, 'updateCategory'])->name('update'); + Route::delete('/{id}', [SalesProductController::class, 'deleteCategory'])->name('destroy'); + }); + + // API (영업 시나리오용) + Route::get('/api/list', [SalesProductController::class, 'getProductsApi'])->name('api.list'); + }); });