Phase 2 - 매핑(Mapping) 관리: - QuoteFormulaMappingController, QuoteFormulaMappingService 추가 - mappings-tab.blade.php 뷰 생성 - 매핑 CRUD 및 순서 변경 API Phase 3 - 품목(Item) 관리: - QuoteFormulaItemController, QuoteFormulaItemService 추가 - items-tab.blade.php 뷰 생성 - 품목 CRUD 및 순서 변경 API - 수량식/단가식 입력 지원 공통: - edit.blade.php에 매핑/품목 탭 연동 - routes/api.php에 API 엔드포인트 추가
333 lines
13 KiB
PHP
333 lines
13 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '수식 수정')
|
|
|
|
@section('content')
|
|
<div x-data="formulaEditor()" x-init="init()" class="container mx-auto max-w-5xl">
|
|
<!-- 헤더 -->
|
|
<div class="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-800">수식 수정</h1>
|
|
<p class="text-sm text-gray-500 mt-1">
|
|
<span x-text="formula?.name || '로딩 중...'"></span>
|
|
<span x-show="formula?.variable" class="ml-2 font-mono text-blue-600" x-text="'(' + formula?.variable + ')'"></span>
|
|
</p>
|
|
</div>
|
|
<a href="{{ route('quote-formulas.index') }}"
|
|
class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
|
← 목록으로
|
|
</a>
|
|
</div>
|
|
|
|
<!-- 로딩 -->
|
|
<div x-show="loading" class="bg-white rounded-lg shadow-sm p-12 text-center">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
|
<p class="text-gray-500 mt-4">데이터를 불러오는 중...</p>
|
|
</div>
|
|
|
|
<!-- 탭 컨테이너 -->
|
|
<div x-show="!loading" x-cloak class="bg-white rounded-lg shadow-sm">
|
|
<!-- 탭 헤더 -->
|
|
<div class="border-b border-gray-200">
|
|
<nav class="flex -mb-px">
|
|
<button @click="activeTab = 'basic'"
|
|
:class="activeTab === 'basic' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
|
class="py-4 px-6 border-b-2 font-medium text-sm transition-colors">
|
|
기본 정보
|
|
</button>
|
|
<button @click="activeTab = 'ranges'"
|
|
:class="activeTab === 'ranges' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
|
class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-center gap-2"
|
|
x-show="formula?.type === 'range'">
|
|
범위 설정
|
|
<span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full" x-text="ranges.length"></span>
|
|
</button>
|
|
<button @click="activeTab = 'mappings'"
|
|
:class="activeTab === 'mappings' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
|
class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-center gap-2"
|
|
x-show="formula?.type === 'mapping'">
|
|
매핑 설정
|
|
<span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full" x-text="mappings.length"></span>
|
|
</button>
|
|
<button @click="activeTab = 'items'"
|
|
:class="activeTab === 'items' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
|
class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-center gap-2"
|
|
x-show="formula?.output_type === 'item'">
|
|
품목 설정
|
|
<span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full" x-text="items.length"></span>
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- 탭 콘텐츠 -->
|
|
<div class="p-6">
|
|
<!-- 기본 정보 탭 -->
|
|
<div x-show="activeTab === 'basic'" x-transition>
|
|
@include('quote-formulas.partials.basic-info-tab')
|
|
</div>
|
|
|
|
<!-- 범위 설정 탭 -->
|
|
<div x-show="activeTab === 'ranges'" x-transition>
|
|
@include('quote-formulas.partials.ranges-tab')
|
|
</div>
|
|
|
|
<!-- 매핑 설정 탭 -->
|
|
<div x-show="activeTab === 'mappings'" x-transition>
|
|
@include('quote-formulas.partials.mappings-tab')
|
|
</div>
|
|
|
|
<!-- 품목 설정 탭 -->
|
|
<div x-show="activeTab === 'items'" x-transition>
|
|
@include('quote-formulas.partials.items-tab')
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
function formulaEditor() {
|
|
return {
|
|
loading: true,
|
|
activeTab: 'basic',
|
|
formula: null,
|
|
categories: [],
|
|
availableVariables: [],
|
|
ranges: [],
|
|
mappings: [],
|
|
items: [],
|
|
form: {
|
|
category_id: '',
|
|
type: '',
|
|
name: '',
|
|
variable: '',
|
|
formula: '',
|
|
description: '',
|
|
sort_order: '',
|
|
is_active: true,
|
|
},
|
|
|
|
async init() {
|
|
await Promise.all([
|
|
this.loadCategories(),
|
|
this.loadVariables(),
|
|
this.loadFormula(),
|
|
]);
|
|
this.loading = false;
|
|
},
|
|
|
|
async loadCategories() {
|
|
try {
|
|
const res = await fetch('/api/admin/quote-formulas/categories/dropdown', {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
});
|
|
const result = await res.json();
|
|
if (result.success) {
|
|
this.categories = result.data;
|
|
}
|
|
} catch (err) {
|
|
console.error('카테고리 로드 실패:', err);
|
|
}
|
|
},
|
|
|
|
async loadVariables() {
|
|
try {
|
|
const res = await fetch('/api/admin/quote-formulas/formulas/variables', {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
});
|
|
const result = await res.json();
|
|
if (result.success) {
|
|
this.availableVariables = result.data;
|
|
}
|
|
} catch (err) {
|
|
console.error('변수 로드 실패:', err);
|
|
}
|
|
},
|
|
|
|
async loadFormula() {
|
|
const formulaId = {{ $id }};
|
|
try {
|
|
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
});
|
|
const result = await res.json();
|
|
|
|
if (res.ok && result.success) {
|
|
this.formula = result.data;
|
|
this.form = {
|
|
category_id: result.data.category_id || '',
|
|
type: result.data.type || '',
|
|
name: result.data.name || '',
|
|
variable: result.data.variable || '',
|
|
formula: result.data.formula || '',
|
|
description: result.data.description || '',
|
|
sort_order: result.data.sort_order || '',
|
|
is_active: result.data.is_active,
|
|
};
|
|
|
|
// 범위 데이터 로드
|
|
if (result.data.type === 'range') {
|
|
await this.loadRanges();
|
|
}
|
|
|
|
// 매핑 데이터 로드
|
|
if (result.data.type === 'mapping') {
|
|
await this.loadMappings();
|
|
}
|
|
|
|
// 품목 데이터 로드
|
|
if (result.data.output_type === 'item') {
|
|
await this.loadItems();
|
|
}
|
|
} else {
|
|
showToast(result.message || '데이터를 불러오는데 실패했습니다.', 'error');
|
|
window.location.href = '{{ route("quote-formulas.index") }}';
|
|
}
|
|
} catch (err) {
|
|
showToast('서버 오류가 발생했습니다.', 'error');
|
|
window.location.href = '{{ route("quote-formulas.index") }}';
|
|
}
|
|
},
|
|
|
|
async loadRanges() {
|
|
const formulaId = {{ $id }};
|
|
try {
|
|
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/ranges`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
});
|
|
const result = await res.json();
|
|
if (result.success) {
|
|
this.ranges = result.data;
|
|
}
|
|
} catch (err) {
|
|
console.error('범위 로드 실패:', err);
|
|
}
|
|
},
|
|
|
|
async loadMappings() {
|
|
const formulaId = {{ $id }};
|
|
try {
|
|
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/mappings`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
});
|
|
const result = await res.json();
|
|
if (result.success) {
|
|
this.mappings = result.data;
|
|
}
|
|
} catch (err) {
|
|
console.error('매핑 로드 실패:', err);
|
|
}
|
|
},
|
|
|
|
async loadItems() {
|
|
const formulaId = {{ $id }};
|
|
try {
|
|
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/items`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
});
|
|
const result = await res.json();
|
|
if (result.success) {
|
|
this.items = result.data;
|
|
}
|
|
} catch (err) {
|
|
console.error('품목 로드 실패:', err);
|
|
}
|
|
},
|
|
|
|
async saveFormula() {
|
|
const formulaId = {{ $id }};
|
|
const errorDiv = document.getElementById('errorMessage');
|
|
errorDiv?.classList.add('hidden');
|
|
|
|
const data = {
|
|
category_id: parseInt(this.form.category_id),
|
|
name: this.form.name,
|
|
variable: this.form.variable,
|
|
type: this.form.type,
|
|
formula: this.form.formula || null,
|
|
description: this.form.description || null,
|
|
sort_order: this.form.sort_order ? parseInt(this.form.sort_order) : null,
|
|
is_active: this.form.is_active,
|
|
};
|
|
|
|
try {
|
|
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}`, {
|
|
method: 'PUT',
|
|
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('수식이 저장되었습니다.', 'success');
|
|
// 폼 데이터 갱신
|
|
await this.loadFormula();
|
|
} else {
|
|
if (errorDiv) {
|
|
errorDiv.textContent = result.message || '저장에 실패했습니다.';
|
|
errorDiv.classList.remove('hidden');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (errorDiv) {
|
|
errorDiv.textContent = '서버 오류가 발생했습니다.';
|
|
errorDiv.classList.remove('hidden');
|
|
}
|
|
}
|
|
},
|
|
|
|
async validateFormula() {
|
|
const resultSpan = document.getElementById('validateResult');
|
|
|
|
if (!this.form.formula) {
|
|
resultSpan.textContent = '';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/quote-formulas/formulas/validate', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
},
|
|
body: JSON.stringify({ formula: this.form.formula })
|
|
});
|
|
const result = await res.json();
|
|
|
|
if (result.success) {
|
|
resultSpan.innerHTML = '<span class="text-green-600">유효한 수식입니다.</span>';
|
|
} else {
|
|
resultSpan.innerHTML = `<span class="text-red-600">${result.data?.errors?.join(', ') || '유효하지 않은 수식입니다.'}</span>`;
|
|
}
|
|
} catch (err) {
|
|
resultSpan.innerHTML = '<span class="text-red-600">검증 중 오류가 발생했습니다.</span>';
|
|
}
|
|
},
|
|
|
|
insertVariable(variable) {
|
|
const input = document.getElementById('formula');
|
|
const cursorPos = input.selectionStart;
|
|
const textBefore = this.form.formula.substring(0, cursorPos);
|
|
const textAfter = this.form.formula.substring(cursorPos);
|
|
this.form.formula = textBefore + variable + textAfter;
|
|
this.$nextTick(() => {
|
|
input.focus();
|
|
input.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length);
|
|
});
|
|
},
|
|
|
|
onVariableInput(e) {
|
|
this.form.variable = e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
@endpush |