Files
sam-manage/resources/views/quote-formulas/simulator.blade.php

1355 lines
63 KiB
PHP
Raw Normal View History

@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