393 lines
20 KiB
PHP
393 lines
20 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '상품관리')
|
|
|
|
@section('content')
|
|
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8" x-data="productManager()">
|
|
{{-- 헤더 --}}
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div class="flex items-center gap-3">
|
|
<div class="p-3 bg-indigo-100 rounded-xl">
|
|
<svg class="w-8 h-8 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>
|
|
<h1 class="text-2xl font-bold text-gray-900">상품관리</h1>
|
|
<p class="text-sm text-gray-500">SAM 솔루션 상품 및 요금 설정</p>
|
|
</div>
|
|
</div>
|
|
<button type="button"
|
|
x-on:click="openCategoryModal()"
|
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
|
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
카테고리 관리
|
|
</button>
|
|
</div>
|
|
|
|
{{-- 카테고리 탭 --}}
|
|
<div class="bg-white rounded-xl shadow-sm">
|
|
<div class="border-b border-gray-200">
|
|
<nav class="flex -mb-px px-4" aria-label="카테고리">
|
|
@foreach($categories as $category)
|
|
<button type="button"
|
|
x-on:click="selectCategory('{{ $category->code }}')"
|
|
:class="currentCategory === '{{ $category->code }}'
|
|
? 'border-indigo-500 text-indigo-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
|
class="whitespace-nowrap py-4 px-6 border-b-2 font-medium text-sm transition-colors">
|
|
{{ $category->name }}
|
|
<span class="ml-2 px-2 py-0.5 text-xs rounded-full"
|
|
:class="currentCategory === '{{ $category->code }}' ? 'bg-indigo-100 text-indigo-600' : 'bg-gray-100 text-gray-500'">
|
|
{{ $category->products->count() }}
|
|
</span>
|
|
</button>
|
|
@endforeach
|
|
</nav>
|
|
</div>
|
|
|
|
{{-- 상품 목록 영역 --}}
|
|
<div class="p-6">
|
|
{{-- 헤더 --}}
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-lg font-semibold text-gray-900" x-text="categoryName"></span>
|
|
<span class="text-sm text-gray-500">(기본 제공: <span x-text="baseStorage"></span>)</span>
|
|
</div>
|
|
<button type="button"
|
|
x-on:click="openProductModal()"
|
|
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 transition-colors">
|
|
<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="M12 4v16m8-8H4" />
|
|
</svg>
|
|
상품 추가
|
|
</button>
|
|
</div>
|
|
|
|
{{-- 상품 카드 그리드 --}}
|
|
<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])
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 상품 추가/수정 모달 --}}
|
|
<div x-show="showProductModal"
|
|
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-lg p-6 relative">
|
|
<button type="button" x-on:click="showProductModal = 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-4" x-text="editingProduct ? '상품 수정' : '상품 추가'"></h3>
|
|
<form x-on:submit.prevent="saveProduct()">
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">상품 코드</label>
|
|
<input type="text" x-model="productForm.code" :disabled="editingProduct"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-100"
|
|
placeholder="basic, quality, ai 등" required>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">상품명</label>
|
|
<input type="text" x-model="productForm.name"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
placeholder="기본형, 품질관리 등" required>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">설명</label>
|
|
<textarea x-model="productForm.description" rows="2"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
placeholder="프로그램 타입 및 포함 기능"></textarea>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">개발비 (가입비)</label>
|
|
<input type="text"
|
|
:value="formatNumber(productForm.development_fee)"
|
|
x-on:input="productForm.development_fee = parseNumber($event.target.value); $event.target.value = formatNumber(productForm.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" required>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">월 구독료</label>
|
|
<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"
|
|
placeholder="0" required>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">수당율 (%)</label>
|
|
<input type="number" x-model="productForm.commission_rate"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
min="0" max="100" step="0.01">
|
|
</div>
|
|
<div class="flex items-end gap-4">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" x-model="productForm.is_required"
|
|
class="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span class="text-sm text-gray-700">필수 상품</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" x-model="productForm.allow_flexible_pricing"
|
|
class="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span class="text-sm text-gray-700">재량권 허용</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end gap-3 mt-6">
|
|
<button type="button" x-on:click="showProductModal = 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="submit"
|
|
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">
|
|
저장
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 카테고리 관리 모달 --}}
|
|
<div x-show="showCategoryModal"
|
|
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">
|
|
<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>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
<div class="border-t pt-4">
|
|
<h4 class="text-sm font-medium text-gray-700 mb-2">새 카테고리 추가</h4>
|
|
<div class="space-y-3">
|
|
<input type="text" x-model="categoryForm.code" placeholder="코드 (영문)"
|
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
|
<input type="text" x-model="categoryForm.name" placeholder="카테고리명"
|
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
|
<button type="button" x-on:click="saveCategory()"
|
|
class="w-full px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">
|
|
추가
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button type="button" x-on:click="showCategoryModal = 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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@push('scripts')
|
|
<script>
|
|
function productManager() {
|
|
return {
|
|
currentCategory: '{{ $currentCategory?->code ?? '' }}',
|
|
categoryName: '{{ $currentCategory?->name ?? '' }}',
|
|
baseStorage: '{{ $currentCategory?->base_storage ?? '100GB' }}',
|
|
categories: @json($categories),
|
|
|
|
showProductModal: false,
|
|
showCategoryModal: false,
|
|
editingProduct: null,
|
|
|
|
productForm: {
|
|
code: '',
|
|
name: '',
|
|
description: '',
|
|
development_fee: 0,
|
|
subscription_fee: 0,
|
|
commission_rate: 25,
|
|
is_required: false,
|
|
allow_flexible_pricing: true,
|
|
},
|
|
|
|
categoryForm: {
|
|
code: '',
|
|
name: '',
|
|
},
|
|
|
|
selectCategory(code) {
|
|
this.currentCategory = code;
|
|
const cat = this.categories.find(c => c.code === code);
|
|
if (cat) {
|
|
this.categoryName = cat.name;
|
|
this.baseStorage = cat.base_storage;
|
|
}
|
|
htmx.ajax('GET', '{{ route("sales.products.list") }}?category=' + code, {
|
|
target: '#product-list',
|
|
swap: 'innerHTML'
|
|
});
|
|
},
|
|
|
|
openProductModal(product = null) {
|
|
this.editingProduct = product;
|
|
if (product) {
|
|
this.productForm = { ...product };
|
|
} else {
|
|
this.productForm = {
|
|
code: '',
|
|
name: '',
|
|
description: '',
|
|
development_fee: 0,
|
|
subscription_fee: 0,
|
|
commission_rate: 25,
|
|
is_required: false,
|
|
allow_flexible_pricing: true,
|
|
};
|
|
}
|
|
this.showProductModal = true;
|
|
},
|
|
|
|
async saveProduct() {
|
|
const cat = this.categories.find(c => c.code === this.currentCategory);
|
|
if (!cat) return;
|
|
|
|
const url = this.editingProduct
|
|
? '{{ url("sales/products") }}/' + this.editingProduct.id
|
|
: '{{ route("sales.products.store") }}';
|
|
const method = this.editingProduct ? 'PUT' : 'POST';
|
|
|
|
const data = {
|
|
...this.productForm,
|
|
category_id: cat.id,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
|
},
|
|
body: JSON.stringify(data),
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
this.showProductModal = false;
|
|
this.selectCategory(this.currentCategory);
|
|
} else {
|
|
alert(result.message || '저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
}
|
|
},
|
|
|
|
async deleteProduct(id) {
|
|
if (!confirm('이 상품을 삭제하시겠습니까?')) return;
|
|
|
|
try {
|
|
const response = await fetch('{{ url("sales/products") }}/' + id, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
|
},
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
this.selectCategory(this.currentCategory);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
},
|
|
|
|
openCategoryModal() {
|
|
this.categoryForm = { code: '', name: '' };
|
|
this.showCategoryModal = true;
|
|
},
|
|
|
|
async saveCategory() {
|
|
if (!this.categoryForm.code || !this.categoryForm.name) {
|
|
alert('코드와 이름을 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('{{ route("sales.products.categories.store") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
|
},
|
|
body: JSON.stringify(this.categoryForm),
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
location.reload();
|
|
} else {
|
|
alert(result.message || '저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
}
|
|
},
|
|
|
|
formatCurrency(value) {
|
|
return '₩' + Number(value).toLocaleString();
|
|
},
|
|
|
|
formatNumber(value) {
|
|
if (value === null || value === undefined || value === '') return '';
|
|
return Math.floor(Number(value)).toLocaleString('ko-KR');
|
|
},
|
|
|
|
parseNumber(value) {
|
|
if (!value) return 0;
|
|
return parseInt(String(value).replace(/[^\d]/g, ''), 10) || 0;
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
@endpush
|
|
@endsection
|