Files
sam-manage/resources/views/quote-formulas/edit.blade.php
hskwon 5742f9a3e4 feat(quote-formula): 매핑/품목 관리 UI 구현 (Phase 2, 3)
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 엔드포인트 추가
2025-12-22 19:07:50 +09:00

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">
&larr; 목록으로
</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