## 구현 내용 ### 모델 (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개 라우트
356 lines
15 KiB
PHP
356 lines
15 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '수식 시뮬레이터')
|
|
|
|
@section('content')
|
|
<div class="container mx-auto max-w-6xl">
|
|
<!-- 헤더 -->
|
|
<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 class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<!-- 입력 영역 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">입력값</h2>
|
|
|
|
<!-- 로딩 -->
|
|
<div id="inputLoading" class="text-center py-8">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
|
<p class="text-gray-500 mt-2 text-sm">입력 변수를 불러오는 중...</p>
|
|
</div>
|
|
|
|
<!-- 입력 폼 -->
|
|
<form id="simulatorForm" class="space-y-4 hidden">
|
|
<!-- 동적으로 생성될 입력 필드 -->
|
|
<div id="inputFields" class="space-y-4"></div>
|
|
|
|
<!-- 에러 메시지 -->
|
|
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700"></div>
|
|
|
|
<!-- 실행 버튼 -->
|
|
<button type="submit" id="runButton"
|
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg font-medium transition-colors flex items-center justify-center gap-2">
|
|
<span>수식 실행</span>
|
|
<svg id="runSpinner" class="hidden animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
|
|
<!-- 입력 변수 없음 -->
|
|
<div id="noInputs" class="hidden text-center py-8 text-gray-500">
|
|
<p>입력 변수가 없습니다.</p>
|
|
<a href="{{ route('quote-formulas.create') }}" class="text-blue-600 hover:text-blue-700 text-sm mt-2 inline-block">
|
|
수식 추가하기 →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 결과 영역 -->
|
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">실행 결과</h2>
|
|
|
|
<!-- 초기 상태 -->
|
|
<div id="resultEmpty" class="text-center text-gray-400 py-12">
|
|
<svg class="w-16 h-16 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 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
|
</svg>
|
|
<p>입력값을 넣고 수식을 실행하세요</p>
|
|
</div>
|
|
|
|
<!-- 로딩 -->
|
|
<div id="resultLoading" class="hidden text-center py-12">
|
|
<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="resultContainer" class="hidden space-y-6">
|
|
<!-- 계산된 변수값 -->
|
|
<div>
|
|
<h3 class="text-sm font-medium text-gray-700 mb-2">계산된 변수</h3>
|
|
<div id="calculatedVariables" class="space-y-2 max-h-60 overflow-y-auto"></div>
|
|
</div>
|
|
|
|
<!-- 생성된 품목 -->
|
|
<div>
|
|
<h3 class="text-sm font-medium text-gray-700 mb-2">생성된 품목</h3>
|
|
<div id="generatedItems" class="space-y-2 max-h-60 overflow-y-auto"></div>
|
|
</div>
|
|
|
|
<!-- 에러가 있을 경우 -->
|
|
<div id="resultErrors" class="hidden">
|
|
<h3 class="text-sm font-medium text-red-700 mb-2">오류</h3>
|
|
<div id="errorList" class="space-y-2"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 수식 실행 순서 -->
|
|
<div class="bg-gray-50 rounded-lg p-6 mt-6">
|
|
<h3 class="text-sm font-semibold text-gray-700 mb-3">수식 실행 순서 (카테고리 순)</h3>
|
|
<div id="categoryOrder" class="flex flex-wrap gap-2">
|
|
<span class="text-sm text-gray-500">카테고리 로딩 중...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
let inputVariables = [];
|
|
let categories = [];
|
|
|
|
// 초기화
|
|
async function init() {
|
|
await Promise.all([
|
|
loadInputVariables(),
|
|
loadCategories()
|
|
]);
|
|
}
|
|
|
|
// 입력 변수 로드
|
|
async function loadInputVariables() {
|
|
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();
|
|
|
|
document.getElementById('inputLoading').classList.add('hidden');
|
|
|
|
if (result.success && result.data) {
|
|
// type이 'input'인 변수만 필터링
|
|
inputVariables = result.data.filter(v => v.type === 'input');
|
|
|
|
if (inputVariables.length === 0) {
|
|
document.getElementById('noInputs').classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
renderInputFields();
|
|
document.getElementById('simulatorForm').classList.remove('hidden');
|
|
} else {
|
|
document.getElementById('noInputs').classList.remove('hidden');
|
|
}
|
|
} catch (err) {
|
|
console.error('입력 변수 로드 실패:', err);
|
|
document.getElementById('inputLoading').classList.add('hidden');
|
|
document.getElementById('noInputs').classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// 카테고리 순서 로드
|
|
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) {
|
|
categories = result.data;
|
|
renderCategoryOrder();
|
|
}
|
|
} catch (err) {
|
|
console.error('카테고리 로드 실패:', err);
|
|
}
|
|
}
|
|
|
|
// 입력 필드 렌더링
|
|
function renderInputFields() {
|
|
const container = document.getElementById('inputFields');
|
|
|
|
// 카테고리별로 그룹화
|
|
const grouped = {};
|
|
inputVariables.forEach(v => {
|
|
const category = v.category || '기타';
|
|
if (!grouped[category]) grouped[category] = [];
|
|
grouped[category].push(v);
|
|
});
|
|
|
|
let html = '';
|
|
for (const [category, vars] of Object.entries(grouped)) {
|
|
html += `
|
|
<div class="space-y-3">
|
|
<h3 class="text-sm font-medium text-gray-600 bg-gray-50 px-3 py-2 rounded-lg">${category}</h3>
|
|
<div class="grid grid-cols-2 gap-3 pl-2">
|
|
`;
|
|
|
|
vars.forEach(v => {
|
|
const defaultValue = v.default_value || '';
|
|
html += `
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1" title="${v.description || ''}">
|
|
${v.variable}
|
|
<span class="text-gray-400 font-normal">${v.name ? `(${v.name})` : ''}</span>
|
|
</label>
|
|
<input type="number"
|
|
name="${v.variable}"
|
|
value="${defaultValue}"
|
|
step="any"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="${v.variable}">
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// 카테고리 순서 렌더링
|
|
function renderCategoryOrder() {
|
|
const container = document.getElementById('categoryOrder');
|
|
|
|
if (categories.length === 0) {
|
|
container.innerHTML = '<span class="text-sm text-gray-500">등록된 카테고리가 없습니다.</span>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = categories.map((cat, index) => `
|
|
<span class="inline-flex items-center gap-1 px-3 py-1 bg-white border border-gray-200 rounded-full text-xs text-gray-600">
|
|
<span class="w-5 h-5 flex items-center justify-center bg-gray-200 rounded-full text-xs font-medium">${index + 1}</span>
|
|
${cat.name}
|
|
</span>
|
|
`).join('');
|
|
}
|
|
|
|
// 폼 제출 (수식 실행)
|
|
document.getElementById('simulatorForm').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const errorDiv = document.getElementById('errorMessage');
|
|
errorDiv.classList.add('hidden');
|
|
|
|
// 입력값 수집
|
|
const formData = new FormData(this);
|
|
const inputs = {};
|
|
for (const [key, value] of formData.entries()) {
|
|
if (value !== '') {
|
|
inputs[key] = parseFloat(value);
|
|
}
|
|
}
|
|
|
|
// UI 상태 변경
|
|
document.getElementById('runButton').disabled = true;
|
|
document.getElementById('runSpinner').classList.remove('hidden');
|
|
document.getElementById('resultEmpty').classList.add('hidden');
|
|
document.getElementById('resultLoading').classList.remove('hidden');
|
|
document.getElementById('resultContainer').classList.add('hidden');
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/quote-formulas/formulas/simulate', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
},
|
|
body: JSON.stringify({ inputs })
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
document.getElementById('resultLoading').classList.add('hidden');
|
|
|
|
if (response.ok && result.success) {
|
|
renderResults(result.data);
|
|
document.getElementById('resultContainer').classList.remove('hidden');
|
|
} else {
|
|
errorDiv.textContent = result.message || '수식 실행에 실패했습니다.';
|
|
errorDiv.classList.remove('hidden');
|
|
document.getElementById('resultEmpty').classList.remove('hidden');
|
|
}
|
|
} catch (err) {
|
|
console.error('수식 실행 오류:', err);
|
|
document.getElementById('resultLoading').classList.add('hidden');
|
|
errorDiv.textContent = '서버 오류가 발생했습니다.';
|
|
errorDiv.classList.remove('hidden');
|
|
document.getElementById('resultEmpty').classList.remove('hidden');
|
|
} finally {
|
|
document.getElementById('runButton').disabled = false;
|
|
document.getElementById('runSpinner').classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// 결과 렌더링
|
|
function renderResults(data) {
|
|
// 계산된 변수
|
|
const variablesContainer = document.getElementById('calculatedVariables');
|
|
const variables = data.variables || {};
|
|
const variableKeys = Object.keys(variables);
|
|
|
|
if (variableKeys.length === 0) {
|
|
variablesContainer.innerHTML = '<p class="text-sm text-gray-500">계산된 변수가 없습니다.</p>';
|
|
} else {
|
|
variablesContainer.innerHTML = variableKeys.map(key => {
|
|
const value = variables[key];
|
|
const formattedValue = typeof value === 'number' ? value.toLocaleString() : value;
|
|
return `
|
|
<div class="flex justify-between items-center py-2 px-3 bg-gray-50 rounded text-sm">
|
|
<span class="font-mono text-gray-700">${key}</span>
|
|
<span class="font-semibold text-blue-600">${formattedValue}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// 생성된 품목
|
|
const itemsContainer = document.getElementById('generatedItems');
|
|
const items = data.items || [];
|
|
|
|
if (items.length === 0) {
|
|
itemsContainer.innerHTML = '<p class="text-sm text-gray-500">생성된 품목이 없습니다.</p>';
|
|
} else {
|
|
itemsContainer.innerHTML = items.map(item => `
|
|
<div class="flex justify-between items-center py-2 px-3 bg-green-50 rounded text-sm">
|
|
<div>
|
|
<span class="font-medium text-gray-800">${item.name || item.item_code}</span>
|
|
${item.specification ? `<span class="text-gray-500 ml-2">${item.specification}</span>` : ''}
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="font-semibold text-green-600">${item.quantity || 0}</span>
|
|
<span class="text-gray-500 ml-1">${item.unit || 'EA'}</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// 오류 처리
|
|
const errors = data.errors || [];
|
|
const errorsContainer = document.getElementById('resultErrors');
|
|
const errorList = document.getElementById('errorList');
|
|
|
|
if (errors.length > 0) {
|
|
errorList.innerHTML = errors.map(err => `
|
|
<div class="py-2 px-3 bg-red-50 rounded text-sm text-red-700">
|
|
${err}
|
|
</div>
|
|
`).join('');
|
|
errorsContainer.classList.remove('hidden');
|
|
} else {
|
|
errorsContainer.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// 초기화 실행
|
|
init();
|
|
</script>
|
|
@endpush
|