Files
sam-manage/resources/views/quote-formulas/simulator.blade.php
kent 33367406a0 feat(MNG): 견적 공식 시뮬레이터 UI 개선
- FormulaEvaluatorService: 공식 평가 로직 개선
- simulator.blade.php: 시뮬레이터 UI/UX 개선
  - 입력 필드 레이아웃 최적화
  - 계산 결과 표시 개선
  - 에러 처리 강화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 17:27:01 +09:00

1355 lines
63 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.app')
@section('title', '수식 시뮬레이터')
@section('content')
<div class="container mx-auto max-w-7xl">
<!-- 헤더 -->
<div class="flex justify-between items-center mb-4">
<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="bg-white rounded-lg shadow-sm p-4 mb-4">
<!-- 로딩 -->
<div id="inputLoading" class="text-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<p class="text-gray-500 mt-2 text-sm">입력 변수를 불러오는 ...</p>
</div>
<!-- 입력 -->
<form id="simulatorForm" class="hidden">
<!-- 동적으로 생성될 입력 필드 (가로 배치) -->
<div id="inputFields" class="flex flex-wrap gap-3 items-end"></div>
<!-- 에러 메시지 -->
<div id="errorMessage" class="hidden mt-3 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700"></div>
<!-- 실행 버튼 -->
<div class="mt-3 flex justify-end">
<button type="submit" id="runButton"
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<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>
</div>
</form>
<!-- 입력 변수 없음 -->
<div id="noInputs" class="hidden text-center py-4 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 id="categoryOrderSection" class="hidden mt-3 pt-3 border-t border-gray-100">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs font-medium text-gray-500">실행순서:</span>
<div id="categoryOrder" class="flex flex-wrap gap-1.5"></div>
</div>
</div>
</div>
<!-- 모드 선택 -->
<div class="bg-white rounded-lg shadow-sm mb-4">
<div class="flex border-b">
<button type="button" id="modeFormula" class="flex-1 px-4 py-3 text-sm font-medium text-blue-600 border-b-2 border-blue-600 bg-blue-50 mode-tab" data-mode="formula">
📊 수식 시뮬레이션
</button>
<button type="button" id="modeBom" class="flex-1 px-4 py-3 text-sm font-medium text-gray-500 hover:text-gray-700 mode-tab" data-mode="bom">
🔧 BOM 디버깅
</button>
</div>
</div>
<!-- BOM 시뮬레이션 섹션 (숨김 기본) -->
<div id="bomSection" class="hidden">
<!-- BOM 입력 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">완제품 BOM 시뮬레이션</h3>
<form id="bomSimulatorForm">
<div class="flex flex-wrap gap-3 items-end">
<!-- 제품 카테고리 (PC) -->
<div class="w-28">
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리</label>
<select id="productCategory" name="PC" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체</option>
<option value="SCREEN">스크린</option>
<option value="STEEL">철재</option>
<option value="BENDING">절곡</option>
<option value="ALUMINUM">알루미늄</option>
</select>
</div>
<!-- 제품명 (완제품 FG) -->
<div class="w-48">
<label class="block text-xs font-medium text-gray-600 mb-1">제품명</label>
<select id="fgCodeSelect" name="finished_goods_code" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택...</option>
</select>
</div>
<!-- (W0) -->
<div class="w-20">
<label class="block text-xs font-medium text-gray-600 mb-1">W0</label>
<input type="number" name="W0" value="2000" min="100" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 높이 (H0) -->
<div class="w-20">
<label class="block text-xs font-medium text-gray-600 mb-1">H0</label>
<input type="number" name="H0" value="2500" min="100" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 가이드레일 설치유형 (GT) -->
<div class="w-28">
<label class="block text-xs font-medium text-gray-600 mb-1">가이드레일</label>
<select id="guideRailType" name="GT" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="wall">벽부착</option>
<option value="ceiling">천장매립</option>
<option value="floor">바닥매립</option>
</select>
</div>
<!-- 모터 전원 (MP) -->
<div class="w-24">
<label class="block text-xs font-medium text-gray-600 mb-1">모터전원</label>
<select id="motorPower" name="MP" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="single">단상</option>
<option value="three">삼상</option>
</select>
</div>
<!-- 연동제어기 (CT) -->
<div class="w-24">
<label class="block text-xs font-medium text-gray-600 mb-1">제어기</label>
<select id="controller" name="CT" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="basic">기본</option>
<option value="smart">스마트</option>
<option value="premium">프리미엄</option>
</select>
</div>
<!-- 마구리 날개치수 (WS) -->
<div class="w-20">
<label class="block text-xs font-medium text-gray-600 mb-1">마구리</label>
<input type="number" id="wingSize" name="WS" value="50" min="0" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 검사비 (INSP) -->
<div class="w-24">
<label class="block text-xs font-medium text-gray-600 mb-1">검사비</label>
<input type="number" id="inspectionFee" name="INSP" value="50000" min="0" step="1000" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 수량 (QTY) -->
<div class="w-16">
<label class="block text-xs font-medium text-gray-600 mb-1">수량</label>
<input type="number" name="QTY" value="1" min="1" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 실행 버튼 -->
<button type="submit" id="runBomButton" class="bg-green-600 hover:bg-green-700 text-white px-4 py-1.5 rounded font-medium text-sm transition-colors flex items-center gap-2">
<span>실행</span>
<svg id="bomSpinner" 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>
</div>
</form>
</div>
<!-- BOM 결과 영역 -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<!-- 디버깅 패널 (10단계) -->
<div class="bg-white rounded-lg shadow-sm p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
🔍 계산 과정 (10단계)
</h3>
<div id="debugSteps" class="space-y-2 text-xs max-h-[60vh] overflow-y-auto">
<p class="text-gray-400 text-center py-4">BOM 계산을 실행하세요</p>
</div>
</div>
<!-- 공정별 품목 그룹 -->
<div class="bg-white rounded-lg shadow-sm p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
🏭 공정별 품목
</h3>
<div id="processGroups" class="space-y-3 max-h-[60vh] overflow-y-auto">
<p class="text-gray-400 text-center py-4 text-xs">BOM 계산을 실행하세요</p>
</div>
</div>
<!-- 소계 합계 -->
<div class="bg-white rounded-lg shadow-sm p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
💰 원가 요약
</h3>
<div id="costSummary" class="space-y-2 text-sm">
<p class="text-gray-400 text-center py-4 text-xs">BOM 계산을 실행하세요</p>
</div>
</div>
</div>
</div>
<!-- 수식 시뮬레이션 섹션 -->
<div id="formulaSection">
<!-- 결과 영역 (2 그리드) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- 좌측: 실행 결과 (계산된 변수) -->
<div class="bg-white rounded-lg shadow-sm p-4">
<h2 class="text-lg font-semibold text-gray-800 mb-3 pb-2 border-b flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600" 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>
계산된 변수
</h2>
<!-- 초기 상태 -->
<div id="resultEmpty" class="text-center text-gray-400 py-8">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" 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 class="text-sm">입력값을 넣고 수식을 실행하세요</p>
</div>
<!-- 로딩 -->
<div id="resultLoading" class="hidden text-center py-8">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 mx-auto"></div>
<p class="text-gray-500 mt-3 text-sm">수식을 실행하는 ...</p>
</div>
<!-- 계산된 변수 결과 -->
<div id="calculatedVariables" class="hidden space-y-2 max-h-[calc(100vh-400px)] overflow-y-auto"></div>
<!-- 에러가 있을 경우 -->
<div id="resultErrors" class="hidden mt-4">
<h3 class="text-sm font-medium text-red-700 mb-2">오류</h3>
<div id="errorList" class="space-y-2"></div>
</div>
</div>
<!-- 우측: 품목 목록 -->
<div class="bg-white rounded-lg shadow-sm p-4">
<!-- 헤더 -->
<div class="flex items-center gap-4 mb-3 pb-2 border-b">
<button type="button" id="tabGenerated" class="flex items-center gap-2 text-lg font-semibold text-gray-400 hover:text-gray-600 transition-colors tab-btn" data-tab="generated">
<svg class="w-5 h-5" 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>
<span>생성된 품목</span>
<span id="generatedCount" class="hidden px-1.5 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">0</span>
</button>
<button type="button" id="tabAllItems" class="flex items-center gap-2 text-lg font-semibold text-green-600 hover:text-green-700 transition-colors tab-btn active" data-tab="all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<span>전체 품목</span>
<span id="allItemsCount" class="px-1.5 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full">0</span>
</button>
</div>
<!-- 전체 품목 -->
<div id="allItemsTab">
<!-- 검색/필터 -->
<div class="flex gap-2 mb-3">
<input type="text" id="itemSearch" placeholder="코드 또는 품명 검색..."
class="flex-1 px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-green-500">
<select id="itemTypeFilter" class="px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-green-500">
<option value="">전체 유형</option>
<option value="FG">완제품 (FG)</option>
<option value="PT">부품 (PT)</option>
<option value="SM">부자재 (SM)</option>
<option value="RM">원자재 (RM)</option>
<option value="CS">소모품 (CS)</option>
</select>
</div>
<!-- 품목 유형 통계 -->
<div id="itemStats" class="flex flex-wrap gap-1.5 mb-3"></div>
<!-- 로딩 -->
<div id="itemsLoading" class="text-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mx-auto"></div>
<p class="text-gray-500 mt-2 text-sm">품목 목록을 불러오는 ...</p>
</div>
<!-- 품목 목록 -->
<div id="allItemsList" class="hidden space-y-1.5 max-h-[calc(100vh-480px)] overflow-y-auto"></div>
</div>
<!-- 생성된 품목 -->
<div id="generatedItemsTab" class="hidden">
<!-- 초기 상태 -->
<div id="itemsEmpty" class="text-center text-gray-400 py-8">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p class="text-sm">수식 실행 생성된 품목이 표시됩니다</p>
</div>
<!-- 생성된 품목 결과 -->
<div id="generatedItems" class="hidden space-y-2 max-h-[calc(100vh-400px)] overflow-y-auto"></div>
</div>
</div>
</div>
</div> <!-- /formulaSection -->
</div>
@endsection
@push('scripts')
<script>
let inputVariables = [];
let categories = [];
let allItems = [];
let filteredItems = [];
// 초기화
async function init() {
await Promise.all([
loadInputVariables(),
loadCategories(),
loadAllItems()
]);
// 탭 이벤트 바인딩
setupTabs();
// 검색/필터 이벤트 바인딩
setupItemFilters();
}
// 전체 품목 로드
async function loadAllItems() {
try {
const response = await fetch('/api/admin/quote-formulas/formulas/items', {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
});
const result = await response.json();
document.getElementById('itemsLoading').classList.add('hidden');
if (result.success && result.data) {
allItems = result.data.items || [];
filteredItems = [...allItems];
// 통계 표시
renderItemStats(result.data.stats || {});
// 품목 목록 표시
renderAllItems();
// 전체 개수 표시
document.getElementById('allItemsCount').textContent = result.data.total || 0;
document.getElementById('allItemsList').classList.remove('hidden');
}
} catch (err) {
console.error('품목 목록 로드 실패:', err);
document.getElementById('itemsLoading').classList.add('hidden');
document.getElementById('allItemsList').innerHTML = '<p class="text-sm text-red-500 text-center py-4">품목을 불러오는데 실패했습니다.</p>';
document.getElementById('allItemsList').classList.remove('hidden');
}
}
// 품목 유형 통계 렌더링
function renderItemStats(stats) {
const container = document.getElementById('itemStats');
const typeLabels = {
'FG': '완제품',
'PT': '부품',
'SM': '부자재',
'RM': '원자재',
'CS': '소모품'
};
const statHtml = Object.entries(stats).map(([type, count]) => {
const color = itemTypeBadgeColors[type] || 'bg-gray-100 text-gray-600';
const label = typeLabels[type] || type;
return `<span class="px-2 py-0.5 ${color} text-xs rounded cursor-pointer hover:opacity-80" onclick="filterByType('${type}')">${label}: ${count}</span>`;
}).join('');
container.innerHTML = statHtml;
}
// 전체 품목 목록 렌더링
function renderAllItems() {
const container = document.getElementById('allItemsList');
if (filteredItems.length === 0) {
container.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">표시할 품목이 없습니다.</p>';
return;
}
container.innerHTML = filteredItems.map(item => {
const badgeColor = itemTypeBadgeColors[item.item_type] || 'bg-gray-100 text-gray-600';
const bomBadge = item.has_bom ? `<span class="text-[10px] text-orange-500">BOM ${item.bom_count}</span>` : '';
return `
<div class="flex items-center gap-2 p-2 bg-gray-50 hover:bg-gray-100 rounded text-sm transition-colors">
<span class="px-1.5 py-0.5 ${badgeColor} text-xs font-medium rounded flex-shrink-0">
${item.item_type_label || item.item_type}
</span>
<span class="font-mono text-xs text-gray-400 flex-shrink-0">${item.code}</span>
<span class="text-gray-700 truncate flex-1" title="${item.name}">${item.name}</span>
${bomBadge}
<span class="text-xs text-gray-400 flex-shrink-0">${item.unit || 'EA'}</span>
</div>
`;
}).join('');
}
// 품목 유형으로 필터
function filterByType(type) {
document.getElementById('itemTypeFilter').value = type;
applyItemFilters();
}
// 검색/필터 이벤트 설정
function setupItemFilters() {
const searchInput = document.getElementById('itemSearch');
const typeFilter = document.getElementById('itemTypeFilter');
let debounceTimer;
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(applyItemFilters, 300);
});
typeFilter.addEventListener('change', applyItemFilters);
}
// 필터 적용
function applyItemFilters() {
const search = document.getElementById('itemSearch').value.toLowerCase().trim();
const itemType = document.getElementById('itemTypeFilter').value;
filteredItems = allItems.filter(item => {
const matchesSearch = !search ||
item.code.toLowerCase().includes(search) ||
item.name.toLowerCase().includes(search);
const matchesType = !itemType || item.item_type === itemType;
return matchesSearch && matchesType;
});
renderAllItems();
}
// 탭 설정
function setupTabs() {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
const tab = this.dataset.tab;
switchTab(tab);
});
});
}
// 탭 전환
function switchTab(tab) {
const allTab = document.getElementById('allItemsTab');
const generatedTab = document.getElementById('generatedItemsTab');
const allBtn = document.getElementById('tabAllItems');
const generatedBtn = document.getElementById('tabGenerated');
if (tab === 'all') {
allTab.classList.remove('hidden');
generatedTab.classList.add('hidden');
allBtn.classList.add('text-green-600');
allBtn.classList.remove('text-gray-400');
generatedBtn.classList.add('text-gray-400');
generatedBtn.classList.remove('text-green-600');
} else {
allTab.classList.add('hidden');
generatedTab.classList.remove('hidden');
allBtn.classList.remove('text-green-600');
allBtn.classList.add('text-gray-400');
generatedBtn.classList.remove('text-gray-400');
generatedBtn.classList.add('text-green-600');
}
}
// 입력 변수 로드
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);
}
}
// 디자인 페이지와 동일한 셀렉트 옵션 정의
const selectOptions = {
'PC': [
{ value: 'screen', label: '스크린' },
{ value: 'slat', label: '슬랫' },
{ value: 'steel', label: '철재' }
],
'PRODUCT_ID': [], // 제품카테고리에 따라 동적으로 변경
'GT': [
{ value: 'back', label: '백면' },
{ value: 'side', label: '측면' },
{ value: 'both', label: '양측' }
],
'MP': [
{ value: '220V', label: '220V' },
{ value: '380V', label: '380V' }
],
'CT': [
{ value: 'none', label: '없음' },
{ value: 'single', label: '단독제어' },
{ value: 'linked', label: '연동제어' },
{ value: 'central', label: '중앙제어' }
],
'CONTROLLER_TYPE': [
{ value: '매립형', label: '매립형' },
{ value: '노출형', label: '노출형' },
{ value: '일체형', label: '일체형' }
]
};
// 제품카테고리별 제품 목록
const productsByCategory = {
'screen': [
{ value: 'screen_standard', label: '스크린 셔터 (표준형)' },
{ value: 'screen_premium', label: '스크린 셔터 (프리미엄)' },
{ value: 'screen_large', label: '스크린 셔터 (대형)' },
{ value: 'screen_corner', label: '스크린 셔터 (코너형)' }
],
'slat': [
{ value: 'slat_steel', label: '철재 슬랫 셔터' },
{ value: 'slat_aluminum', label: '알루미늄 슬랫 셔터' }
],
'steel': [
{ value: 'steel_door', label: '철재문' },
{ value: 'steel_shutter', label: '철재 셔터' }
]
};
// 입력 필드 렌더링 (가로 배치)
function renderInputFields() {
const container = document.getElementById('inputFields');
let html = '';
inputVariables.forEach(v => {
const defaultValue = v.default_value || '';
let inputHtml = '';
// 셀렉트 옵션이 정의된 변수인 경우
if (selectOptions[v.variable]) {
const options = selectOptions[v.variable];
const optionsHtml = options.map(opt =>
`<option value="${opt.value}">${opt.label}</option>`
).join('');
// 제품명은 비활성화 상태로 시작 (카테고리 선택 후 활성화)
const isDisabled = v.variable === 'PRODUCT_ID' ? 'disabled' : '';
inputHtml = `
<select name="${v.variable}" id="input_${v.variable}"
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${isDisabled ? 'bg-gray-100' : ''}"
${isDisabled}>
<option value="">선택</option>
${optionsHtml}
</select>
`;
}
// 수량(QTY)은 숫자 입력
else if (v.variable === 'QTY') {
inputHtml = `
<input type="number"
name="${v.variable}"
id="input_${v.variable}"
value="1"
min="1"
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
`;
}
// 기타 숫자 입력 (W0, H0 등)
else {
inputHtml = `
<input type="number"
name="${v.variable}"
id="input_${v.variable}"
value="${defaultValue}"
step="any"
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="${v.variable}">
`;
}
// 너비 클래스 결정 (셀렉트는 조금 더 넓게)
const widthClass = selectOptions[v.variable] ? 'w-32' : 'w-24';
html += `
<div class="${widthClass}">
<label class="block text-xs font-medium text-gray-600 mb-1 truncate" title="${v.description || v.name || v.variable}">
${v.variable}
${v.name ? `<span class="text-gray-400">(${v.name})</span>` : ''}
</label>
${inputHtml}
</div>
`;
});
container.innerHTML = html;
// 제품카테고리 변경 시 제품명 옵션 업데이트
const pcSelect = document.getElementById('input_PC');
const productSelect = document.getElementById('input_PRODUCT_ID');
if (pcSelect && productSelect) {
pcSelect.addEventListener('change', function() {
const category = this.value;
const products = productsByCategory[category] || [];
productSelect.innerHTML = '<option value="">선택</option>';
products.forEach(p => {
productSelect.innerHTML += `<option value="${p.value}">${p.label}</option>`;
});
// 카테고리 선택 시 제품명 활성화
if (category) {
productSelect.disabled = false;
productSelect.classList.remove('bg-gray-100');
} else {
productSelect.disabled = true;
productSelect.classList.add('bg-gray-100');
}
});
}
}
// 카테고리 순서 렌더링
function renderCategoryOrder() {
const section = document.getElementById('categoryOrderSection');
const container = document.getElementById('categoryOrder');
if (categories.length === 0) {
section.classList.add('hidden');
return;
}
container.innerHTML = categories.map((cat, index) => `
<span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-gray-100 border border-gray-200 rounded text-xs text-gray-600">
<span class="w-3.5 h-3.5 flex items-center justify-center bg-gray-300 rounded text-xs font-medium">${index + 1}</span>
${cat.name}
</span>
`).join('');
section.classList.remove('hidden');
}
// 폼 제출 (수식 실행)
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('itemsEmpty').classList.add('hidden');
document.getElementById('resultLoading').classList.remove('hidden');
document.getElementById('calculatedVariables').classList.add('hidden');
document.getElementById('generatedItems').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);
} else {
errorDiv.textContent = result.message || '수식 실행에 실패했습니다.';
errorDiv.classList.remove('hidden');
document.getElementById('resultEmpty').classList.remove('hidden');
document.getElementById('itemsEmpty').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');
document.getElementById('itemsEmpty').classList.remove('hidden');
} finally {
document.getElementById('runButton').disabled = false;
document.getElementById('runSpinner').classList.add('hidden');
}
});
// 결과 렌더링
function renderResults(data, hasErrors = false) {
// 계산된 변수
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 text-center py-4">계산된 변수가 없습니다.</p>';
} else {
// 경고 배너 (일부 오류가 있는 경우)
let warningHtml = '';
if (hasErrors) {
warningHtml = `
<div class="mb-3 p-2 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-700">
⚠️ 일부 수식에서 오류가 발생했습니다.
</div>
`;
}
variablesContainer.innerHTML = warningHtml + 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 : '';
// 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-1.5 px-2 bg-gray-50 rounded text-sm">
<div class="truncate mr-2">
<span class="font-mono text-gray-700">${key}</span>
${varName ? `<span class="text-gray-400 text-xs ml-1">${varName}</span>` : ''}
</div>
<span class="font-semibold text-blue-600 whitespace-nowrap">${formattedValue}</span>
</div>
`;
}).join('');
}
variablesContainer.classList.remove('hidden');
// 생성된 품목
const itemsContainer = document.getElementById('generatedItems');
const items = data.items || [];
if (items.length === 0) {
itemsContainer.innerHTML = '<p class="text-sm text-gray-500 text-center py-4">생성된 품목이 없습니다.</p>';
} else {
itemsContainer.innerHTML = items.map(item => renderItemCard(item)).join('');
// 트리 토글 이벤트 바인딩
itemsContainer.querySelectorAll('.bom-toggle').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const targetId = this.dataset.target;
const targetEl = document.getElementById(targetId);
const icon = this.querySelector('svg');
if (targetEl.classList.contains('hidden')) {
targetEl.classList.remove('hidden');
icon.classList.add('rotate-90');
} else {
targetEl.classList.add('hidden');
icon.classList.remove('rotate-90');
}
});
});
}
itemsContainer.classList.remove('hidden');
// 생성된 품목 개수 표시 및 탭 전환
const generatedCount = document.getElementById('generatedCount');
generatedCount.textContent = items.length;
generatedCount.classList.remove('hidden');
// 생성된 품목 탭으로 전환
switchTab('generated');
// 오류 처리
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-1.5 px-2 bg-red-50 rounded text-sm text-red-700">
${err}
</div>
`).join('');
errorsContainer.classList.remove('hidden');
} else {
errorsContainer.classList.add('hidden');
}
}
// 품목 유형별 배지 색상
const itemTypeBadgeColors = {
'FG': 'bg-purple-100 text-purple-700', // 완제품
'PT': 'bg-blue-100 text-blue-700', // 부품
'SM': 'bg-green-100 text-green-700', // 부자재
'RM': 'bg-yellow-100 text-yellow-700', // 원자재
'CS': 'bg-gray-100 text-gray-600' // 소모품
};
let itemIdCounter = 0;
// 품목 카드 렌더링
function renderItemCard(item) {
const itemId = `item_${itemIdCounter++}`;
const hasBom = item.has_bom && item.bom_children && item.bom_children.length > 0;
const badgeColor = itemTypeBadgeColors[item.item_type] || 'bg-gray-100 text-gray-600';
// 가격 정보 포맷
const unitPrice = item.unit_price ? item.unit_price.toLocaleString() : '-';
const totalPrice = item.total_price ? item.total_price.toLocaleString() : '-';
return `
<div class="border border-gray-200 rounded-lg overflow-hidden">
<!-- 품목 헤더 -->
<div class="p-2.5 bg-gradient-to-r from-green-50 to-white">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
${hasBom ? `
<button type="button" class="bom-toggle p-0.5 hover:bg-gray-200 rounded transition-colors" data-target="${itemId}_bom">
<svg class="w-4 h-4 text-gray-500 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
` : '<span class="w-5"></span>'}
<span class="px-1.5 py-0.5 ${badgeColor} text-xs font-medium rounded">
${item.item_type_label || item.item_type || '미등록'}
</span>
<span class="font-mono text-xs text-gray-500">${item.item_code}</span>
</div>
<div class="ml-5 text-sm font-medium text-gray-800 truncate" title="${item.item_name}">
${item.item_name}
</div>
${item.description ? `<div class="ml-5 text-xs text-gray-500 truncate mt-0.5" title="${item.description}">${item.description}</div>` : ''}
${item.specification ? `<div class="ml-5 text-xs text-gray-400 mt-0.5">${item.specification}</div>` : ''}
</div>
<div class="text-right flex-shrink-0 ml-3">
<div class="text-sm font-semibold text-green-600">
${item.quantity || 0} <span class="text-gray-500 font-normal">${item.unit || 'EA'}</span>
</div>
<div class="text-xs text-gray-500 mt-0.5">
@${unitPrice}
</div>
<div class="text-xs font-medium text-gray-700">
= ${totalPrice}
</div>
</div>
</div>
</div>
<!-- BOM 트리 (있는 경우) -->
${hasBom ? `
<div id="${itemId}_bom" class="hidden border-t border-gray-100 bg-gray-50/50">
<div class="p-2 pl-6">
<div class="text-xs font-medium text-gray-500 mb-1.5 flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"/>
</svg>
BOM 구성 (${item.bom_children.length}개 하위 품목)
</div>
${renderBomTree(item.bom_children, item.quantity || 1, 0)}
</div>
</div>
` : ''}
</div>
`;
}
// BOM 트리 재귀 렌더링
function renderBomTree(children, parentQty = 1, depth = 0) {
if (!children || children.length === 0) return '';
const maxDepth = 5; // 최대 깊이 제한
if (depth >= maxDepth) return '<div class="text-xs text-gray-400 ml-4">...</div>';
return `
<div class="space-y-1 ${depth > 0 ? 'ml-4 pl-2 border-l border-gray-200' : ''}">
${children.map(child => {
const childBadgeColor = itemTypeBadgeColors[child.item_type] || 'bg-gray-100 text-gray-600';
const calculatedQty = (child.quantity || 1) * parentQty;
const hasChildren = child.has_bom && child.children && child.children.length > 0;
const childId = `bom_${itemIdCounter++}`;
return `
<div class="text-xs">
<div class="flex items-center gap-1.5 py-1 px-1.5 bg-white rounded hover:bg-gray-50">
${hasChildren ? `
<button type="button" class="bom-toggle p-0.5 hover:bg-gray-200 rounded" data-target="${childId}">
<svg class="w-3 h-3 text-gray-400 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
` : '<span class="w-4"></span>'}
<span class="px-1 py-0.5 ${childBadgeColor} text-[10px] rounded">
${child.item_type_label || child.item_type || '-'}
</span>
<span class="font-mono text-gray-400">${child.code}</span>
<span class="text-gray-700 truncate flex-1">${child.name}</span>
<span class="text-gray-500 whitespace-nowrap">
${child.quantity || 1} × ${parentQty} =
<span class="font-medium text-gray-700">${calculatedQty}</span>
${child.unit || 'EA'}
</span>
</div>
${hasChildren ? `
<div id="${childId}" class="hidden">
${renderBomTree(child.children, calculatedQty, depth + 1)}
</div>
` : ''}
</div>
`;
}).join('')}
</div>
`;
}
// =========================================================================
// BOM 시뮬레이션 기능
// =========================================================================
// 모드 전환 설정
function setupModeTabs() {
document.querySelectorAll('.mode-tab').forEach(tab => {
tab.addEventListener('click', function() {
const mode = this.dataset.mode;
switchMode(mode);
});
});
}
// 모드 전환
function switchMode(mode) {
const formulaSection = document.getElementById('formulaSection');
const bomSection = document.getElementById('bomSection');
const modeFormula = document.getElementById('modeFormula');
const modeBom = document.getElementById('modeBom');
if (mode === 'formula') {
formulaSection.classList.remove('hidden');
bomSection.classList.add('hidden');
modeFormula.classList.add('text-blue-600', 'border-b-2', 'border-blue-600', 'bg-blue-50');
modeFormula.classList.remove('text-gray-500');
modeBom.classList.remove('text-green-600', 'border-b-2', 'border-green-600', 'bg-green-50');
modeBom.classList.add('text-gray-500');
} else {
formulaSection.classList.add('hidden');
bomSection.classList.remove('hidden');
modeBom.classList.add('text-green-600', 'border-b-2', 'border-green-600', 'bg-green-50');
modeBom.classList.remove('text-gray-500');
modeFormula.classList.remove('text-blue-600', 'border-b-2', 'border-blue-600', 'bg-blue-50');
modeFormula.classList.add('text-gray-500');
// FG 목록 로드 및 카테고리 필터 설정
loadFinishedGoods();
setupCategoryFilter();
}
}
// 완제품 목록 저장 (카테고리 필터링용)
let allFinishedGoods = [];
// 완제품 목록 로드
async function loadFinishedGoods() {
const select = document.getElementById('fgCodeSelect');
if (allFinishedGoods.length > 0) {
// 이미 로드됨 - 필터링만 적용
filterFinishedGoods();
return;
}
try {
const response = await fetch('/api/admin/quote-formulas/formulas/items?item_type=FG', {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
});
const result = await response.json();
if (result.success && result.data && result.data.items) {
allFinishedGoods = result.data.items;
filterFinishedGoods();
}
} catch (err) {
console.error('완제품 목록 로드 실패:', err);
}
}
// 카테고리별 완제품 필터링
function filterFinishedGoods() {
const select = document.getElementById('fgCodeSelect');
const categorySelect = document.getElementById('productCategory');
const selectedCategory = categorySelect ? categorySelect.value : '';
// 기존 옵션 초기화
select.innerHTML = '<option value="">제품을 선택하세요...</option>';
// 필터링
const filtered = selectedCategory
? allFinishedGoods.filter(item => {
const itemCategory = (item.item_category || '').toUpperCase();
return itemCategory === selectedCategory.toUpperCase();
})
: allFinishedGoods;
// 옵션 추가
filtered.forEach(item => {
const option = document.createElement('option');
option.value = item.code;
option.dataset.category = item.item_category || '';
option.textContent = item.name; // 제품명만 표시
select.appendChild(option);
});
}
// 카테고리 변경 이벤트
function setupCategoryFilter() {
const categorySelect = document.getElementById('productCategory');
if (categorySelect) {
categorySelect.addEventListener('change', filterFinishedGoods);
}
}
// BOM 시뮬레이션 폼 제출
document.getElementById('bomSimulatorForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const fgCode = formData.get('finished_goods_code');
if (!fgCode) {
alert('완제품을 선택하세요.');
return;
}
const inputVars = {
W0: parseFloat(formData.get('W0')) || 2000,
H0: parseFloat(formData.get('H0')) || 2500,
QTY: parseInt(formData.get('QTY')) || 1,
// 새 변수들 (React 동기화)
PC: formData.get('PC') || '',
GT: formData.get('GT') || 'wall',
MP: formData.get('MP') || 'single',
CT: formData.get('CT') || 'basic',
WS: parseInt(formData.get('WS')) || 50,
INSP: parseInt(formData.get('INSP')) || 50000
};
// UI 상태 변경
document.getElementById('runBomButton').disabled = true;
document.getElementById('bomSpinner').classList.remove('hidden');
try {
const response = await fetch('/api/admin/quote-formulas/formulas/simulate-bom', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
finished_goods_code: fgCode,
input_variables: inputVars
})
});
const result = await response.json();
if (result.success && result.data) {
renderBomResults(result.data);
} else {
alert(result.message || 'BOM 계산에 실패했습니다.');
resetBomResults();
}
} catch (err) {
console.error('BOM 계산 오류:', err);
alert('서버 오류가 발생했습니다.');
resetBomResults();
} finally {
document.getElementById('runBomButton').disabled = false;
document.getElementById('bomSpinner').classList.add('hidden');
}
});
// BOM 결과 초기화
function resetBomResults() {
document.getElementById('debugSteps').innerHTML = '<p class="text-gray-400 text-center py-4">BOM 계산을 실행하세요</p>';
document.getElementById('processGroups').innerHTML = '<p class="text-gray-400 text-center py-4 text-xs">BOM 계산을 실행하세요</p>';
document.getElementById('costSummary').innerHTML = '<p class="text-gray-400 text-center py-4 text-xs">BOM 계산을 실행하세요</p>';
}
// BOM 결과 렌더링
function renderBomResults(data) {
renderDebugSteps(data.debug_steps || []);
renderProcessGroups(data.grouped_items || {});
renderCostSummary(data.subtotals || {}, data.grand_total || 0, data.finished_goods || {});
}
// 디버그 스텝 아이콘
const stepIcons = {
1: '📥', 2: '🔢', 3: '📦', 4: '🌳', 5: '💵',
6: '✖️', 7: '💰', 8: '🏭', 9: '📊', 10: '🎯'
};
// 디버깅 패널 렌더링
function renderDebugSteps(steps) {
const container = document.getElementById('debugSteps');
if (!steps || steps.length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-4">디버깅 정보가 없습니다.</p>';
return;
}
container.innerHTML = steps.map(step => {
const icon = stepIcons[step.step] || '•';
const statusColor = step.status === 'success' ? 'text-green-600' : step.status === 'error' ? 'text-red-600' : 'text-blue-600';
const bgColor = step.status === 'success' ? 'bg-green-50 border-green-200' : step.status === 'error' ? 'bg-red-50 border-red-200' : 'bg-blue-50 border-blue-200';
// 상세 정보 렌더링
let detailHtml = '';
if (step.details) {
if (typeof step.details === 'object') {
const entries = Object.entries(step.details);
if (entries.length > 0) {
detailHtml = `
<div class="mt-1 pl-6 text-[10px] text-gray-500">
${entries.slice(0, 5).map(([k, v]) => {
const val = typeof v === 'object' ? JSON.stringify(v) : v;
return `<div><span class="text-gray-400">${k}:</span> ${val}</div>`;
}).join('')}
${entries.length > 5 ? `<div class="text-gray-400">... +${entries.length - 5}개</div>` : ''}
</div>
`;
}
} else {
detailHtml = `<div class="mt-1 pl-6 text-[10px] text-gray-500">${step.details}</div>`;
}
}
return `
<div class="p-2 rounded border ${bgColor}">
<div class="flex items-start gap-2">
<span class="text-sm">${icon}</span>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium ${statusColor}">Step ${step.step}</span>
<span class="text-gray-600">${step.name || ''}</span>
</div>
<div class="text-gray-500 mt-0.5">${step.message || ''}</div>
${detailHtml}
</div>
</div>
</div>
`;
}).join('');
}
// 공정명 한글화
const processLabels = {
'screen': '🎬 스크린 공정',
'bending': '🔧 절곡 공정',
'steel': '🔩 철재 공정',
'electric': '⚡ 전기 공정',
'assembly': '🔨 조립 공정',
'unknown': '❓ 기타'
};
// 공정별 그룹 렌더링
function renderProcessGroups(groupedItems) {
const container = document.getElementById('processGroups');
const processKeys = Object.keys(groupedItems);
if (processKeys.length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-4 text-xs">그룹화된 품목이 없습니다.</p>';
return;
}
container.innerHTML = processKeys.map(processType => {
const group = groupedItems[processType];
const items = group.items || [];
const subtotal = group.subtotal || 0;
const label = processLabels[processType] || processType;
return `
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-gray-100 flex items-center justify-between">
<span class="font-medium text-gray-700 text-xs">${label}</span>
<span class="text-xs text-gray-500">${items.length}개 품목</span>
</div>
<div class="p-2 space-y-1 text-xs">
${items.slice(0, 8).map(item => renderProcessItem(item)).join('')}
${items.length > 8 ? `<div class="text-gray-400 text-center py-1">... +${items.length - 8}개 더</div>` : ''}
</div>
<div class="px-3 py-1.5 bg-gray-50 text-right border-t">
<span class="text-xs text-gray-500">소계:</span>
<span class="font-medium text-gray-700 ml-1">${subtotal.toLocaleString()}원</span>
</div>
</div>
`;
}).join('');
}
// 공정별 품목 아이템 렌더링
function renderProcessItem(item) {
const price = item.total_price || item.amount || 0;
const qty = item.quantity || 0;
const categoryNote = item.calculation_note || '';
return `
<div class="flex items-center gap-2 py-1 px-2 bg-white rounded hover:bg-gray-50">
<span class="font-mono text-gray-400 truncate w-20">${item.item_code || ''}</span>
<span class="text-gray-700 truncate flex-1">${item.item_name || ''}</span>
<span class="text-gray-500 whitespace-nowrap">${qty} ${item.unit || 'EA'}</span>
<span class="text-green-600 font-medium whitespace-nowrap">${price.toLocaleString()}원</span>
</div>
`;
}
// 원가 요약 렌더링
function renderCostSummary(subtotals, grandTotal, finishedGoods) {
const container = document.getElementById('costSummary');
// 완제품 정보
let fgHtml = '';
if (finishedGoods && finishedGoods.code) {
fgHtml = `
<div class="p-3 bg-purple-50 rounded-lg border border-purple-200 mb-3">
<div class="text-xs text-purple-600 font-medium">완제품</div>
<div class="font-medium text-gray-800">${finishedGoods.code}</div>
<div class="text-sm text-gray-600">${finishedGoods.name || ''}</div>
</div>
`;
}
// 공정별 소계
const subtotalEntries = Object.entries(subtotals);
let subtotalsHtml = '';
if (subtotalEntries.length > 0) {
subtotalsHtml = `
<div class="space-y-2 mb-4">
<div class="text-xs font-medium text-gray-500 uppercase">공정별 소계</div>
${subtotalEntries.map(([process, data]) => {
const label = processLabels[process] || process;
// data는 객체 {name, count, subtotal} 또는 숫자일 수 있음
const amount = typeof data === 'object' ? (data.subtotal || 0) : data;
return `
<div class="flex justify-between items-center py-1.5 px-2 bg-gray-50 rounded">
<span class="text-gray-600">${label}</span>
<span class="font-medium text-gray-700">${amount.toLocaleString()}원</span>
</div>
`;
}).join('')}
</div>
`;
}
// 총합계
const totalHtml = `
<div class="p-3 bg-green-50 rounded-lg border border-green-200">
<div class="flex justify-between items-center">
<span class="font-medium text-gray-700">총 원가</span>
<span class="text-xl font-bold text-green-600">${grandTotal.toLocaleString()}원</span>
</div>
</div>
`;
container.innerHTML = fgHtml + subtotalsHtml + totalHtml;
}
// 초기화 실행
init();
setupModeTabs();
</script>
@endpush