Files
sam-manage/resources/views/quote-formulas/simulator.blade.php
hskwon 51f898981d feat: [quote] 시뮬레이터 CONTROLLER_TYPE 입력 지원
- CONTROLLER_TYPE select 드롭다운 추가 (매립형/노출형/일체형)
- 문자열 입력값 지원 (isNaN 체크로 숫자/문자열 구분)
2025-12-04 16:23:27 +09:00

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">
&larr; 수식 목록
</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