feat: [상품관리] 카테고리별 최저 개발비/최저 구독료 설정 기능 추가

- 카테고리 관리에서 최저 개발비, 최저 구독료 설정 가능
- 상품 추가/수정 시 최저가 이하 입력 차단 (서버 검증)
- 상품 목록에 최저가 안내 배너 표시 (경고 아이콘)
- 상품 모달에서 실시간 최저가 미달 경고 표시 (빨간 테두리)
This commit is contained in:
김보곤
2026-03-14 11:52:52 +09:00
parent fa2f023ee3
commit bd81eebf07
3 changed files with 237 additions and 10 deletions

View File

@@ -68,6 +68,13 @@ public function store(Request $request): JsonResponse
'is_required' => 'boolean',
]);
// 최저가 검증
$category = SalesProductCategory::findOrFail($validated['category_id']);
$minFeeErrors = $this->validateMinFees($category, $validated);
if ($minFeeErrors) {
return response()->json(['success' => false, 'message' => $minFeeErrors], 422);
}
// 코드 중복 체크
$exists = SalesProduct::where('category_id', $validated['category_id'])
->where('code', $validated['code'])
@@ -115,6 +122,14 @@ public function update(Request $request, int $id): JsonResponse
'is_active' => 'boolean',
]);
// 최저가 검증
$category = $product->category;
$checkData = array_merge($product->toArray(), $validated);
$minFeeErrors = $this->validateMinFees($category, $checkData);
if ($minFeeErrors) {
return response()->json(['success' => false, 'message' => $minFeeErrors], 422);
}
$product->update($validated);
return response()->json([
@@ -224,6 +239,8 @@ public function updateCategory(Request $request, int $id): JsonResponse
'name' => 'sometimes|string|max:100',
'description' => 'nullable|string',
'base_storage' => 'nullable|string|max:20',
'min_development_fee' => 'nullable|numeric|min:0',
'min_subscription_fee' => 'nullable|numeric|min:0',
'is_active' => 'boolean',
]);
@@ -259,6 +276,30 @@ public function deleteCategory(int $id): JsonResponse
]);
}
// ==================== 내부 헬퍼 ====================
/**
* 최저가 검증
*/
private function validateMinFees(SalesProductCategory $category, array $data): ?string
{
$errors = [];
if ($category->min_development_fee > 0 && isset($data['registration_fee'])) {
if ($data['registration_fee'] < $category->min_development_fee) {
$errors[] = '개발비(할인가)는 최저 개발비 ₩'.number_format($category->min_development_fee).' 이상이어야 합니다.';
}
}
if ($category->min_subscription_fee > 0 && isset($data['subscription_fee'])) {
if ($data['subscription_fee'] < $category->min_subscription_fee) {
$errors[] = '월 구독료는 최저 구독료 ₩'.number_format($category->min_subscription_fee).' 이상이어야 합니다.';
}
}
return $errors ? implode(' ', $errors) : null;
}
// ==================== API (영업 시나리오용) ====================
/**

View File

@@ -14,6 +14,8 @@
* @property string $name
* @property string|null $description
* @property string $base_storage
* @property float $min_development_fee
* @property float $min_subscription_fee
* @property int $display_order
* @property bool $is_active
*/
@@ -22,6 +24,7 @@ class SalesProductCategory extends Model
use SoftDeletes;
protected $connection = 'codebridge';
protected $table = 'sales_product_categories';
protected $fillable = [
@@ -29,11 +32,15 @@ class SalesProductCategory extends Model
'name',
'description',
'base_storage',
'min_development_fee',
'min_subscription_fee',
'display_order',
'is_active',
];
protected $casts = [
'min_development_fee' => 'decimal:2',
'min_subscription_fee' => 'decimal:2',
'display_order' => 'integer',
'is_active' => 'boolean',
];

View File

@@ -67,6 +67,28 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white b
</button>
</div>
{{-- 최저가 안내 --}}
<template x-if="currentMinDevFee > 0 || currentMinSubFee > 0">
<div class="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-amber-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="text-sm">
<p class="font-semibold text-amber-800">최저가 설정 ( 금액 이하로 절대 내릴 없습니다)</p>
<div class="flex gap-4 mt-1 text-amber-700">
<template x-if="currentMinDevFee > 0">
<span>최저 개발비: <strong x-text="'₩' + Number(currentMinDevFee).toLocaleString()"></strong></span>
</template>
<template x-if="currentMinSubFee > 0">
<span>최저 구독료: <strong x-text="'₩' + Number(currentMinSubFee).toLocaleString()"></strong></span>
</template>
</div>
</div>
</div>
</div>
</template>
{{-- 상품 카드 그리드 --}}
<div id="product-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@include('sales.products.partials.product-list', ['category' => $currentCategory])
@@ -129,9 +151,17 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
<input type="text"
:value="formatNumber(productForm.registration_fee)"
x-on:input="productForm.registration_fee = parseNumber($event.target.value); $event.target.value = formatNumber(productForm.registration_fee)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right bg-indigo-50"
:class="currentMinDevFee > 0 && productForm.registration_fee < currentMinDevFee ? 'border-red-400 ring-1 ring-red-400' : 'border-gray-300'"
class="w-full px-3 py-2 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right bg-indigo-50"
placeholder="0" required>
<p class="text-xs text-gray-500 mt-1">기본: 개발비의 25%</p>
<p class="text-xs mt-1" :class="currentMinDevFee > 0 && productForm.registration_fee < currentMinDevFee ? 'text-red-500 font-medium' : 'text-gray-500'">
<template x-if="currentMinDevFee > 0 && productForm.registration_fee < currentMinDevFee">
<span>최저 개발비 <span x-text="Number(currentMinDevFee).toLocaleString()"></span> 이상 필수</span>
</template>
<template x-if="!(currentMinDevFee > 0 && productForm.registration_fee < currentMinDevFee)">
<span>기본: 개발비의 25%</span>
</template>
</p>
</div>
</div>
<div>
@@ -139,8 +169,12 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
<input type="text"
:value="formatNumber(productForm.subscription_fee)"
x-on:input="productForm.subscription_fee = parseNumber($event.target.value); $event.target.value = formatNumber(productForm.subscription_fee)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right"
:class="currentMinSubFee > 0 && productForm.subscription_fee < currentMinSubFee ? 'border-red-400 ring-1 ring-red-400' : 'border-gray-300'"
class="w-full px-3 py-2 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right"
placeholder="0" required>
<template x-if="currentMinSubFee > 0 && productForm.subscription_fee < currentMinSubFee">
<p class="text-xs text-red-500 font-medium mt-1">최저 구독료 <span x-text="Number(currentMinSubFee).toLocaleString()"></span> 이상 필수</p>
</template>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
@@ -184,6 +218,72 @@ class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:b
</div>
</div>
{{-- 최저가 설정 모달 --}}
<div x-show="showMinFeeModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm"></div>
<div class="min-h-screen px-4 flex items-center justify-center relative">
<div class="bg-white rounded-xl shadow-xl w-full max-w-md p-6 relative">
<button type="button" x-on:click="showMinFeeModal = false"
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h3 class="text-lg font-bold text-gray-900 mb-1">최저가 설정</h3>
<p class="text-sm text-gray-500 mb-4" x-text="minFeeForm.categoryName"></p>
<div class="p-3 bg-red-50 border border-red-200 rounded-lg mb-4">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-sm text-red-700">설정된 최저가 이하로는 <strong>절대</strong> 상품 가격을 내릴 없습니다. 영업 할인 협상 시에도 금액이 하한선이 됩니다.</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">최저 개발비 (할인가 기준)</label>
<input type="text"
:value="formatNumber(minFeeForm.min_development_fee)"
x-on:input="minFeeForm.min_development_fee = parseNumber($event.target.value); $event.target.value = formatNumber(minFeeForm.min_development_fee)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right"
placeholder="0">
<p class="text-xs text-gray-500 mt-1">0 입력 제한 없음</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">최저 구독료 ()</label>
<input type="text"
:value="formatNumber(minFeeForm.min_subscription_fee)"
x-on:input="minFeeForm.min_subscription_fee = parseNumber($event.target.value); $event.target.value = formatNumber(minFeeForm.min_subscription_fee)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 text-right"
placeholder="0">
<p class="text-xs text-gray-500 mt-1">0 입력 제한 없음</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button" x-on:click="showMinFeeModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button type="button" x-on:click="saveMinFees()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">
저장
</button>
</div>
</div>
</div>
</div>
{{-- 카테고리 관리 모달 --}}
<div x-show="showCategoryModal"
x-cloak
@@ -201,14 +301,33 @@ class="fixed inset-0 z-50 overflow-y-auto"
<h3 class="text-lg font-bold text-gray-900 mb-4">카테고리 관리</h3>
<div class="space-y-3 mb-4">
@foreach($categories as $category)
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<div class="font-medium text-gray-900">{{ $category->name }}</div>
<div class="text-xs text-gray-500">{{ $category->code }} / {{ $category->base_storage }}</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ $category->products->count() }} 상품</span>
<div class="p-3 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between">
<div>
<div class="font-medium text-gray-900">{{ $category->name }}</div>
<div class="text-xs text-gray-500">{{ $category->code }} / {{ $category->base_storage }}</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ $category->products->count() }} 상품</span>
<button type="button"
x-on:click="openMinFeeModal({{ $category->id }}, '{{ $category->name }}', {{ $category->min_development_fee }}, {{ $category->min_subscription_fee }})"
class="p-1 text-gray-400 hover:text-indigo-600 transition-colors" title="최저가 설정">
<svg 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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
</div>
@if($category->min_development_fee > 0 || $category->min_subscription_fee > 0)
<div class="mt-2 pt-2 border-t border-gray-200 flex gap-4 text-xs text-amber-700">
@if($category->min_development_fee > 0)
<span>최저 개발비: {{ number_format($category->min_development_fee) }}</span>
@endif
@if($category->min_subscription_fee > 0)
<span>최저 구독료: {{ number_format($category->min_subscription_fee) }}</span>
@endif
</div>
@endif
</div>
@endforeach
</div>
@@ -243,12 +362,22 @@ function productManager() {
currentCategory: '{{ $currentCategory?->code ?? '' }}',
categoryName: '{{ $currentCategory?->name ?? '' }}',
baseStorage: '{{ $currentCategory?->base_storage ?? '100GB' }}',
currentMinDevFee: {{ $currentCategory?->min_development_fee ?? 0 }},
currentMinSubFee: {{ $currentCategory?->min_subscription_fee ?? 0 }},
categories: @json($categories),
showProductModal: false,
showCategoryModal: false,
showMinFeeModal: false,
editingProduct: null,
minFeeForm: {
categoryId: null,
categoryName: '',
min_development_fee: 0,
min_subscription_fee: 0,
},
productForm: {
code: '',
name: '',
@@ -273,6 +402,8 @@ function productManager() {
if (cat) {
this.categoryName = cat.name;
this.baseStorage = cat.base_storage;
this.currentMinDevFee = Number(cat.min_development_fee) || 0;
this.currentMinSubFee = Number(cat.min_subscription_fee) || 0;
}
htmx.ajax('GET', '{{ route("sales.products.list") }}?category=' + code, {
target: '#product-list',
@@ -410,6 +541,54 @@ function productManager() {
this.productForm.development_fee = fee;
// 개발비 자동 계산 (개발비의 25%)
this.productForm.registration_fee = Math.floor(fee * 0.25);
},
openMinFeeModal(categoryId, categoryName, minDevFee, minSubFee) {
this.minFeeForm = {
categoryId: categoryId,
categoryName: categoryName,
min_development_fee: Number(minDevFee) || 0,
min_subscription_fee: Number(minSubFee) || 0,
};
this.showMinFeeModal = true;
},
async saveMinFees() {
try {
const response = await fetch('{{ url("sales/products/categories") }}/' + this.minFeeForm.categoryId, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({
min_development_fee: this.minFeeForm.min_development_fee,
min_subscription_fee: this.minFeeForm.min_subscription_fee,
}),
});
const result = await response.json();
if (result.success) {
this.showMinFeeModal = false;
// 카테고리 데이터 갱신
const cat = this.categories.find(c => c.id === this.minFeeForm.categoryId);
if (cat) {
cat.min_development_fee = this.minFeeForm.min_development_fee;
cat.min_subscription_fee = this.minFeeForm.min_subscription_fee;
}
// 현재 선택된 카테고리면 상단 안내 갱신
if (cat && cat.code === this.currentCategory) {
this.currentMinDevFee = this.minFeeForm.min_development_fee;
this.currentMinSubFee = this.minFeeForm.min_subscription_fee;
}
location.reload();
} else {
alert(result.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error(error);
alert('저장 중 오류가 발생했습니다.');
}
}
};
}