- QuoteFormulaRangeService, RangeController 생성 - 범위 CRUD API 엔드포인트 추가 (6개) - edit.blade.php 탭 구조로 개편 (기본정보/범위/매핑/품목) - ranges-tab.blade.php 범위 관리 UI 구현 - Alpine.js 기반 인터랙티브 CRUD
337 lines
17 KiB
PHP
337 lines
17 KiB
PHP
{{-- 범위 설정 탭 --}}
|
|
<div x-data="rangesManager()" x-init="init()">
|
|
<!-- 헤더 -->
|
|
<div class="flex justify-between items-center mb-4">
|
|
<div>
|
|
<h3 class="text-lg font-medium text-gray-800">범위 설정</h3>
|
|
<p class="text-sm text-gray-500 mt-1">조건 변수의 값에 따라 다른 결과를 반환합니다.</p>
|
|
</div>
|
|
<button @click="openAddModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition flex items-center gap-2">
|
|
<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"></path>
|
|
</svg>
|
|
범위 추가
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 조건 변수 입력 (첫 범위 추가 시 사용) -->
|
|
<div x-show="ranges.length === 0" class="mb-4 p-4 bg-gray-50 rounded-lg">
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
조건 변수 <span class="text-red-500">*</span>
|
|
</label>
|
|
<input type="text" x-model="conditionVariable"
|
|
@input="conditionVariable = $event.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '')"
|
|
class="w-full max-w-xs px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
|
placeholder="예: K, H1, S">
|
|
<p class="text-xs text-gray-500 mt-1">범위 조건에 사용할 변수명을 입력하세요 (예: 중량 K, 높이 H1)</p>
|
|
</div>
|
|
|
|
<!-- 조건 변수 표시 (범위가 있을 때) -->
|
|
<div x-show="ranges.length > 0" class="mb-4">
|
|
<span class="text-sm text-gray-600">조건 변수:</span>
|
|
<span class="ml-2 font-mono bg-blue-100 text-blue-700 px-3 py-1 rounded" x-text="conditionVariable || '-'"></span>
|
|
</div>
|
|
|
|
<!-- 범위 목록 테이블 -->
|
|
<div x-show="ranges.length > 0" class="overflow-x-auto">
|
|
<table class="w-full border border-gray-200 rounded-lg overflow-hidden">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-12">#</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">최소값</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">최대값</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">결과값</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">유형</th>
|
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-32">액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white divide-y divide-gray-200">
|
|
<template x-for="(range, index) in ranges" :key="range.id">
|
|
<tr class="hover:bg-gray-50">
|
|
<td class="px-4 py-3 text-sm text-gray-500" x-text="index + 1"></td>
|
|
<td class="px-4 py-3">
|
|
<span class="font-mono text-sm" x-text="range.min_value !== null ? range.min_value : '-'"></span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class="font-mono text-sm" x-text="range.max_value !== null ? range.max_value : '-'"></span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class="font-mono text-sm bg-gray-100 px-2 py-1 rounded" x-text="getResultDisplay(range)"></span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class="text-xs px-2 py-1 rounded-full"
|
|
:class="range.result_type === 'fixed' ? 'bg-green-100 text-green-700' : 'bg-purple-100 text-purple-700'"
|
|
x-text="range.result_type === 'fixed' ? '고정값' : '수식'"></span>
|
|
</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<button @click="editRange(range)" class="text-blue-600 hover:text-blue-800 text-sm mr-2">수정</button>
|
|
<button @click="deleteRange(range.id)" class="text-red-600 hover:text-red-800 text-sm">삭제</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- 빈 상태 -->
|
|
<div x-show="ranges.length === 0" class="text-center py-12 text-gray-500 border-2 border-dashed border-gray-200 rounded-lg">
|
|
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"></path>
|
|
</svg>
|
|
<p class="text-lg font-medium mb-1">설정된 범위가 없습니다</p>
|
|
<p class="text-sm mb-4">위의 "범위 추가" 버튼을 클릭하여 첫 번째 범위를 추가하세요.</p>
|
|
</div>
|
|
|
|
<!-- 도움말 -->
|
|
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
|
|
<h4 class="text-sm font-medium text-gray-700 mb-2">범위 설정 가이드</h4>
|
|
<ul class="text-sm text-gray-600 space-y-1">
|
|
<li><span class="font-mono text-blue-600">min_value</span>: 이 값 이상일 때 적용 (비어있으면 제한 없음)</li>
|
|
<li><span class="font-mono text-blue-600">max_value</span>: 이 값 이하일 때 적용 (비어있으면 제한 없음)</li>
|
|
<li><span class="font-mono text-blue-600">result_value</span>: 조건 충족 시 반환할 값 또는 수식</li>
|
|
</ul>
|
|
<div class="mt-3 p-3 bg-white rounded border border-gray-200">
|
|
<p class="text-xs font-medium text-gray-500 mb-2">예시: 모터 용량 선택 (조건 변수: K)</p>
|
|
<table class="text-xs w-full">
|
|
<tr><td class="pr-4">0 ~ 150</td><td class="font-mono">150K</td></tr>
|
|
<tr><td class="pr-4">150 ~ 300</td><td class="font-mono">300K</td></tr>
|
|
<tr><td class="pr-4">300 ~ 400</td><td class="font-mono">400K</td></tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 범위 추가/수정 모달 -->
|
|
<div x-show="showModal" x-cloak
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
@keydown.escape.window="closeModal()">
|
|
<div class="flex items-center justify-center min-h-screen px-4">
|
|
<!-- 오버레이 -->
|
|
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" @click="closeModal()"></div>
|
|
|
|
<!-- 모달 콘텐츠 -->
|
|
<div class="relative bg-white rounded-lg shadow-xl max-w-lg w-full p-6 transform transition-all"
|
|
@click.stop>
|
|
<h3 class="text-lg font-bold text-gray-800 mb-4" x-text="editingRange ? '범위 수정' : '범위 추가'"></h3>
|
|
|
|
<form @submit.prevent="saveRange()">
|
|
<!-- 조건 변수 (추가 시에만) -->
|
|
<div x-show="!editingRange && ranges.length === 0" class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
조건 변수 <span class="text-red-500">*</span>
|
|
</label>
|
|
<input type="text" x-model="rangeForm.condition_variable"
|
|
@input="rangeForm.condition_variable = $event.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '')"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
|
placeholder="예: K">
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">최소값</label>
|
|
<input type="number" step="0.0001" x-model="rangeForm.min_value"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="비어있으면 제한없음">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">최대값</label>
|
|
<input type="number" step="0.0001" x-model="rangeForm.max_value"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="비어있으면 제한없음">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
결과 유형
|
|
</label>
|
|
<div class="flex gap-4">
|
|
<label class="flex items-center">
|
|
<input type="radio" x-model="rangeForm.result_type" value="fixed"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300">
|
|
<span class="ml-2 text-sm text-gray-700">고정값</span>
|
|
</label>
|
|
<label class="flex items-center">
|
|
<input type="radio" x-model="rangeForm.result_type" value="formula"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300">
|
|
<span class="ml-2 text-sm text-gray-700">수식</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-6">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
결과값 <span class="text-red-500">*</span>
|
|
</label>
|
|
<textarea x-model="rangeForm.result_value" rows="2"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
|
:placeholder="rangeForm.result_type === 'fixed' ? '예: 150K' : '예: CEIL(K / 100) * 100'"
|
|
required></textarea>
|
|
<p class="text-xs text-gray-500 mt-1" x-show="rangeForm.result_type === 'formula'">
|
|
수식에서 조건 변수(<span class="font-mono" x-text="conditionVariable || 'K'"></span>)를 사용할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-3">
|
|
<button type="button" @click="closeModal()"
|
|
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
|
취소
|
|
</button>
|
|
<button type="submit"
|
|
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
|
저장
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function rangesManager() {
|
|
return {
|
|
showModal: false,
|
|
editingRange: null,
|
|
conditionVariable: '',
|
|
rangeForm: {
|
|
min_value: '',
|
|
max_value: '',
|
|
result_value: '',
|
|
result_type: 'fixed',
|
|
condition_variable: '',
|
|
},
|
|
|
|
init() {
|
|
// 부모 컴포넌트의 ranges 데이터 사용
|
|
this.$watch('$root.ranges', (ranges) => {
|
|
if (ranges.length > 0 && ranges[0].condition_variable) {
|
|
this.conditionVariable = ranges[0].condition_variable;
|
|
}
|
|
});
|
|
|
|
// 초기 조건 변수 설정
|
|
if (this.$root.ranges && this.$root.ranges.length > 0) {
|
|
this.conditionVariable = this.$root.ranges[0].condition_variable;
|
|
}
|
|
},
|
|
|
|
get ranges() {
|
|
return this.$root.ranges || [];
|
|
},
|
|
|
|
getResultDisplay(range) {
|
|
return range.result_value || '-';
|
|
},
|
|
|
|
openAddModal() {
|
|
this.editingRange = null;
|
|
this.rangeForm = {
|
|
min_value: '',
|
|
max_value: '',
|
|
result_value: '',
|
|
result_type: 'fixed',
|
|
condition_variable: this.conditionVariable,
|
|
};
|
|
this.showModal = true;
|
|
},
|
|
|
|
editRange(range) {
|
|
this.editingRange = range;
|
|
this.rangeForm = {
|
|
min_value: range.min_value ?? '',
|
|
max_value: range.max_value ?? '',
|
|
result_value: range.result_value || '',
|
|
result_type: range.result_type || 'fixed',
|
|
condition_variable: range.condition_variable,
|
|
};
|
|
this.showModal = true;
|
|
},
|
|
|
|
closeModal() {
|
|
this.showModal = false;
|
|
this.editingRange = null;
|
|
},
|
|
|
|
async saveRange() {
|
|
const formulaId = {{ $id }};
|
|
const isEdit = !!this.editingRange;
|
|
|
|
// 조건 변수 설정 (첫 범위 추가 시)
|
|
if (!isEdit && this.ranges.length === 0) {
|
|
if (!this.rangeForm.condition_variable) {
|
|
showToast('조건 변수를 입력해주세요.', 'error');
|
|
return;
|
|
}
|
|
this.conditionVariable = this.rangeForm.condition_variable;
|
|
}
|
|
|
|
const data = {
|
|
min_value: this.rangeForm.min_value === '' ? null : parseFloat(this.rangeForm.min_value),
|
|
max_value: this.rangeForm.max_value === '' ? null : parseFloat(this.rangeForm.max_value),
|
|
result_value: this.rangeForm.result_value,
|
|
result_type: this.rangeForm.result_type,
|
|
condition_variable: this.conditionVariable || this.rangeForm.condition_variable,
|
|
};
|
|
|
|
const url = isEdit
|
|
? `/api/admin/quote-formulas/formulas/${formulaId}/ranges/${this.editingRange.id}`
|
|
: `/api/admin/quote-formulas/formulas/${formulaId}/ranges`;
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: isEdit ? 'PUT' : 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
},
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
const result = await res.json();
|
|
|
|
if (res.ok && result.success) {
|
|
showToast(result.message, 'success');
|
|
this.closeModal();
|
|
// 부모 컴포넌트의 loadRanges 호출
|
|
await this.$root.loadRanges();
|
|
} else {
|
|
showToast(result.message || '저장에 실패했습니다.', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('범위 저장 실패:', err);
|
|
showToast('서버 오류가 발생했습니다.', 'error');
|
|
}
|
|
},
|
|
|
|
async deleteRange(rangeId) {
|
|
if (!confirm('이 범위를 삭제하시겠습니까?')) return;
|
|
|
|
const formulaId = {{ $id }};
|
|
|
|
try {
|
|
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/ranges/${rangeId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
},
|
|
});
|
|
|
|
const result = await res.json();
|
|
|
|
if (res.ok && result.success) {
|
|
showToast(result.message, 'success');
|
|
// 부모 컴포넌트의 loadRanges 호출
|
|
await this.$root.loadRanges();
|
|
} else {
|
|
showToast(result.message || '삭제에 실패했습니다.', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('범위 삭제 실패:', err);
|
|
showToast('서버 오류가 발생했습니다.', 'error');
|
|
}
|
|
},
|
|
};
|
|
}
|
|
</script> |