## 구현 내용 ### 모델 (5개) - QuoteFormulaCategory: 수식 카테고리 - QuoteFormula: 수식 정의 (input/calculation/range/mapping) - QuoteFormulaRange: 범위별 값 정의 - QuoteFormulaMapping: 매핑 테이블 - QuoteFormulaItem: 수식-품목 연결 ### 서비스 (3개) - QuoteFormulaCategoryService: 카테고리 CRUD - QuoteFormulaService: 수식 CRUD, 복제, 재정렬 - FormulaEvaluatorService: 수식 계산 엔진 - 지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF, AND, OR, NOT ### API Controller (2개) - QuoteFormulaCategoryController: 카테고리 API (11개 엔드포인트) - QuoteFormulaController: 수식 API (16개 엔드포인트) ### FormRequest (4개) - Store/Update QuoteFormulaCategoryRequest - Store/Update QuoteFormulaRequest ### Blade Views (8개) - 수식 목록/추가/수정/시뮬레이터 - 카테고리 목록/추가/수정 - HTMX 테이블 partial ### 라우트 - API: 27개 엔드포인트 - Web: 7개 라우트
364 lines
16 KiB
PHP
364 lines
16 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '수식 수정')
|
|
|
|
@section('content')
|
|
<div class="container mx-auto max-w-4xl">
|
|
<!-- 헤더 -->
|
|
<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">수식 정보를 수정합니다.</p>
|
|
</div>
|
|
<a href="{{ route('quote-formulas.index') }}"
|
|
class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
|
← 목록으로
|
|
</a>
|
|
</div>
|
|
|
|
<!-- 로딩 -->
|
|
<div id="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 id="formContainer" class="bg-white rounded-lg shadow-sm p-6 hidden">
|
|
<form id="formulaForm" class="space-y-6">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<!-- 카테고리 -->
|
|
<div>
|
|
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">
|
|
카테고리 <span class="text-red-500">*</span>
|
|
</label>
|
|
<select name="category_id" id="category_id"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
required>
|
|
<option value="">카테고리 선택</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 수식 유형 -->
|
|
<div>
|
|
<label for="type" class="block text-sm font-medium text-gray-700 mb-1">
|
|
수식 유형 <span class="text-red-500">*</span>
|
|
</label>
|
|
<select name="type" id="type"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
required>
|
|
<option value="">유형 선택</option>
|
|
<option value="input">입력값</option>
|
|
<option value="calculation">계산식</option>
|
|
<option value="range">범위별</option>
|
|
<option value="mapping">매핑</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<!-- 수식명 -->
|
|
<div>
|
|
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
|
|
수식명 <span class="text-red-500">*</span>
|
|
</label>
|
|
<input type="text" name="name" id="name"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
required>
|
|
</div>
|
|
|
|
<!-- 변수명 -->
|
|
<div>
|
|
<label for="variable" class="block text-sm font-medium text-gray-700 mb-1">
|
|
변수명 <span class="text-red-500">*</span>
|
|
</label>
|
|
<input type="text" name="variable" id="variable"
|
|
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"
|
|
required>
|
|
<p class="text-xs text-gray-500 mt-1">대문자로 시작, 대문자/숫자/언더스코어만 사용</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 계산식 (type=calculation 일 때 표시) -->
|
|
<div id="formulaSection" class="hidden">
|
|
<label for="formula" class="block text-sm font-medium text-gray-700 mb-1">
|
|
계산식 <span class="text-red-500">*</span>
|
|
</label>
|
|
<textarea name="formula" id="formula" rows="3"
|
|
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"></textarea>
|
|
<div class="flex items-center gap-4 mt-2">
|
|
<button type="button" onclick="validateFormula()"
|
|
class="text-sm text-blue-600 hover:text-blue-700">
|
|
수식 검증
|
|
</button>
|
|
<span id="validateResult" class="text-sm"></span>
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-1">지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF</p>
|
|
</div>
|
|
|
|
<!-- 사용 가능한 변수 목록 -->
|
|
<div id="variablesSection" class="hidden">
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">사용 가능한 변수</label>
|
|
<div id="variablesList" class="flex flex-wrap gap-2 p-3 bg-gray-50 rounded-lg max-h-32 overflow-y-auto">
|
|
<span class="text-sm text-gray-500">변수를 불러오는 중...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 설명 -->
|
|
<div>
|
|
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">
|
|
설명
|
|
</label>
|
|
<textarea name="description" id="description" 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"></textarea>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<!-- 정렬 순서 -->
|
|
<div>
|
|
<label for="sort_order" class="block text-sm font-medium text-gray-700 mb-1">
|
|
정렬 순서
|
|
</label>
|
|
<input type="number" name="sort_order" id="sort_order"
|
|
min="1"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
</div>
|
|
|
|
<!-- 활성 상태 -->
|
|
<div class="flex items-center pt-6">
|
|
<input type="checkbox" name="is_active" id="is_active" value="1"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<label for="is_active" class="ml-2 block text-sm text-gray-700">
|
|
활성화
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 에러 메시지 -->
|
|
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700"></div>
|
|
|
|
<!-- 버튼 -->
|
|
<div class="flex justify-end gap-3 pt-4 border-t">
|
|
<a href="{{ route('quote-formulas.index') }}"
|
|
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
|
취소
|
|
</a>
|
|
<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>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
const formulaId = {{ $id }};
|
|
let availableVariables = [];
|
|
|
|
// 카테고리 로드
|
|
async function loadCategories() {
|
|
try {
|
|
const response = await fetch('/api/admin/quote-formulas/categories/dropdown', {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
});
|
|
const result = await response.json();
|
|
if (result.success && result.data) {
|
|
const select = document.getElementById('category_id');
|
|
result.data.forEach(cat => {
|
|
const option = document.createElement('option');
|
|
option.value = cat.id;
|
|
option.textContent = cat.name;
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('카테고리 로드 실패:', err);
|
|
}
|
|
}
|
|
|
|
// 변수 목록 로드
|
|
async function loadVariables() {
|
|
try {
|
|
const response = await fetch('/api/admin/quote-formulas/formulas/variables', {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
});
|
|
const result = await response.json();
|
|
if (result.success && result.data) {
|
|
availableVariables = result.data;
|
|
renderVariables();
|
|
}
|
|
} catch (err) {
|
|
console.error('변수 로드 실패:', err);
|
|
}
|
|
}
|
|
|
|
// 변수 목록 렌더링
|
|
function renderVariables() {
|
|
const container = document.getElementById('variablesList');
|
|
if (availableVariables.length === 0) {
|
|
container.innerHTML = '<span class="text-sm text-gray-500">사용 가능한 변수가 없습니다.</span>';
|
|
return;
|
|
}
|
|
container.innerHTML = availableVariables.map(v =>
|
|
`<button type="button" onclick="insertVariable('${v.variable}')"
|
|
class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-mono hover:bg-blue-200"
|
|
title="${v.name} (${v.category})">
|
|
${v.variable}
|
|
</button>`
|
|
).join('');
|
|
}
|
|
|
|
// 변수 삽입
|
|
function insertVariable(variable) {
|
|
const formulaInput = document.getElementById('formula');
|
|
const cursorPos = formulaInput.selectionStart;
|
|
const textBefore = formulaInput.value.substring(0, cursorPos);
|
|
const textAfter = formulaInput.value.substring(cursorPos);
|
|
formulaInput.value = textBefore + variable + textAfter;
|
|
formulaInput.focus();
|
|
formulaInput.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length);
|
|
}
|
|
|
|
// 데이터 로드
|
|
async function loadFormula() {
|
|
await loadCategories();
|
|
await loadVariables();
|
|
|
|
try {
|
|
const response = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
const data = result.data;
|
|
document.getElementById('category_id').value = data.category_id || '';
|
|
document.getElementById('type').value = data.type || '';
|
|
document.getElementById('name').value = data.name || '';
|
|
document.getElementById('variable').value = data.variable || '';
|
|
document.getElementById('formula').value = data.formula || '';
|
|
document.getElementById('description').value = data.description || '';
|
|
document.getElementById('sort_order').value = data.sort_order || '';
|
|
document.getElementById('is_active').checked = data.is_active;
|
|
|
|
// 수식 유형에 따라 섹션 표시
|
|
if (data.type === 'calculation') {
|
|
document.getElementById('formulaSection').classList.remove('hidden');
|
|
document.getElementById('variablesSection').classList.remove('hidden');
|
|
}
|
|
|
|
document.getElementById('loading').classList.add('hidden');
|
|
document.getElementById('formContainer').classList.remove('hidden');
|
|
} else {
|
|
alert(result.message || '데이터를 불러오는데 실패했습니다.');
|
|
window.location.href = '{{ route("quote-formulas.index") }}';
|
|
}
|
|
} catch (err) {
|
|
alert('서버 오류가 발생했습니다.');
|
|
window.location.href = '{{ route("quote-formulas.index") }}';
|
|
}
|
|
}
|
|
|
|
// 수식 유형 변경
|
|
document.getElementById('type').addEventListener('change', function() {
|
|
const formulaSection = document.getElementById('formulaSection');
|
|
const variablesSection = document.getElementById('variablesSection');
|
|
|
|
if (this.value === 'calculation') {
|
|
formulaSection.classList.remove('hidden');
|
|
variablesSection.classList.remove('hidden');
|
|
} else {
|
|
formulaSection.classList.add('hidden');
|
|
variablesSection.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// 수식 검증
|
|
async function validateFormula() {
|
|
const formula = document.getElementById('formula').value;
|
|
const resultSpan = document.getElementById('validateResult');
|
|
|
|
if (!formula) {
|
|
resultSpan.textContent = '';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = 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 })
|
|
});
|
|
const result = await response.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>';
|
|
}
|
|
}
|
|
|
|
// 변수명 자동 대문자 변환
|
|
document.getElementById('variable').addEventListener('input', function() {
|
|
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
|
|
});
|
|
|
|
// 폼 제출
|
|
document.getElementById('formulaForm').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const errorDiv = document.getElementById('errorMessage');
|
|
errorDiv.classList.add('hidden');
|
|
|
|
const formData = new FormData(this);
|
|
const data = {
|
|
category_id: parseInt(formData.get('category_id')),
|
|
name: formData.get('name'),
|
|
variable: formData.get('variable'),
|
|
type: formData.get('type'),
|
|
formula: formData.get('formula') || null,
|
|
description: formData.get('description') || null,
|
|
sort_order: formData.get('sort_order') ? parseInt(formData.get('sort_order')) : null,
|
|
is_active: formData.has('is_active')
|
|
};
|
|
|
|
try {
|
|
const response = 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 response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
window.location.href = '{{ route("quote-formulas.index") }}';
|
|
} else {
|
|
errorDiv.textContent = result.message || '저장에 실패했습니다.';
|
|
errorDiv.classList.remove('hidden');
|
|
}
|
|
} catch (err) {
|
|
errorDiv.textContent = '서버 오류가 발생했습니다.';
|
|
errorDiv.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
// 초기화
|
|
loadFormula();
|
|
</script>
|
|
@endpush
|