437 lines
19 KiB
PHP
437 lines
19 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 || '';
|
|
// TYPE이 포함된 변수는 문자열 입력 (select 또는 text)
|
|
const isTextInput = v.variable.includes('TYPE') || v.variable.includes('_CODE');
|
|
|
|
let inputHtml = '';
|
|
if (isTextInput && v.variable === 'CONTROLLER_TYPE') {
|
|
// 제어기 유형은 select로 표시
|
|
inputHtml = `
|
|
<select name="${v.variable}"
|
|
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">
|
|
<option value="">선택하세요</option>
|
|
<option value="매립형">매립형</option>
|
|
<option value="노출형">노출형</option>
|
|
<option value="일체형">일체형</option>
|
|
</select>
|
|
`;
|
|
} else if (isTextInput) {
|
|
inputHtml = `
|
|
<input type="text"
|
|
name="${v.variable}"
|
|
value="${defaultValue}"
|
|
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}">
|
|
`;
|
|
} else {
|
|
inputHtml = `
|
|
<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}">
|
|
`;
|
|
}
|
|
|
|
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>
|
|
${inputHtml}
|
|
</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 !== '') {
|
|
// 숫자로 변환 가능하면 숫자로, 아니면 문자열로 유지
|
|
const numValue = parseFloat(value);
|
|
inputs[key] = isNaN(numValue) ? value : numValue;
|
|
}
|
|
}
|
|
|
|
// 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({ input_variables: inputs })
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
document.getElementById('resultLoading').classList.add('hidden');
|
|
|
|
if (response.ok && result.success) {
|
|
renderResults(result.data, result.has_errors);
|
|
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, hasErrors = false) {
|
|
// 경고 배너 (일부 오류가 있는 경우)
|
|
const resultContainer = document.getElementById('resultContainer');
|
|
let warningBanner = document.getElementById('warningBanner');
|
|
if (!warningBanner) {
|
|
warningBanner = document.createElement('div');
|
|
warningBanner.id = 'warningBanner';
|
|
warningBanner.className = 'mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-700';
|
|
resultContainer.insertBefore(warningBanner, resultContainer.firstChild);
|
|
}
|
|
|
|
if (hasErrors) {
|
|
warningBanner.innerHTML = '⚠️ 일부 수식에서 오류가 발생했습니다. 아래 오류 섹션을 확인하세요.';
|
|
warningBanner.classList.remove('hidden');
|
|
} else {
|
|
warningBanner.classList.add('hidden');
|
|
}
|
|
|
|
// 계산된 변수
|
|
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 varInfo = variables[key];
|
|
// varInfo가 객체면 value 추출, 아니면 직접 사용
|
|
let rawValue = varInfo && typeof varInfo === 'object' ? varInfo.value : varInfo;
|
|
const varName = varInfo && typeof varInfo === 'object' ? varInfo.name : '';
|
|
const category = varInfo && typeof varInfo === 'object' ? varInfo.category : '';
|
|
|
|
// JSON 문자열인 경우 파싱 시도
|
|
if (typeof rawValue === 'string' && rawValue.startsWith('{')) {
|
|
try {
|
|
rawValue = JSON.parse(rawValue);
|
|
} catch (e) {
|
|
// 파싱 실패 시 원본 유지
|
|
}
|
|
}
|
|
|
|
// 값 포맷팅
|
|
let formattedValue;
|
|
if (rawValue === null || rawValue === undefined) {
|
|
formattedValue = '<span class="text-gray-400">null</span>';
|
|
} else if (typeof rawValue === 'object') {
|
|
// Range 결과 등 객체인 경우 - value 필드 우선, 없으면 전체 표시
|
|
if (rawValue.value) {
|
|
formattedValue = `<span title="${rawValue.note || ''}">${rawValue.value}</span>`;
|
|
} else {
|
|
formattedValue = JSON.stringify(rawValue);
|
|
}
|
|
} else if (typeof rawValue === 'number') {
|
|
formattedValue = rawValue.toLocaleString();
|
|
} else {
|
|
formattedValue = rawValue;
|
|
}
|
|
|
|
return `
|
|
<div class="flex justify-between items-center py-2 px-3 bg-gray-50 rounded text-sm">
|
|
<div>
|
|
<span class="font-mono text-gray-700">${key}</span>
|
|
${varName ? `<span class="text-gray-400 text-xs ml-2">${varName}</span>` : ''}
|
|
</div>
|
|
<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
|