Files
sam-manage/resources/views/item-management/index.blade.php
김보곤 73e4a83e78 feat: [item-management] BOM 트리 3단계 구조 구현 (FG → 카테고리 → PT)
- BOM에 category 필드가 있으면 중간 그룹 노드 자동 생성
- 1단계: FG 완제품, 2단계: 주자재/모터/제어기/절곡품/부자재, 3단계: PT 부품
- 카테고리 노드는 건수 표시, 접힘/펼침 지원
- 카테고리 노드 클릭 시 우측 상세 이동하지 않음
2026-03-18 15:41:43 +09:00

814 lines
38 KiB
PHP

@extends('layouts.app')
@section('title', '품목관리')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
<h1 class="text-2xl font-bold text-gray-800">품목관리</h1>
</div>
<!-- 3-Panel 레이아웃 -->
<div class="flex gap-4" style="height: calc(100vh - 180px);">
<!-- 좌측 패널: 품목 리스트 -->
<div class="w-72 flex-shrink-0 bg-white rounded-lg shadow-sm flex flex-col overflow-hidden">
<!-- 검색 -->
<div class="p-3 border-b border-gray-200">
<input type="text"
id="item-search"
placeholder="코드 또는 품목명 검색..."
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<!-- 유형 필터 -->
<div class="flex flex-wrap gap-1 mt-2">
<button type="button" data-type="" class="item-type-filter active px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 font-medium" title="모든 품목 유형">전체</button>
<button type="button" data-type="FG" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-blue-50" title="Finished Goods (완제품)">FG</button>
<button type="button" data-type="PT" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-green-50" title="Parts (반제품/부품)">PT</button>
<button type="button" data-type="SM" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-yellow-50" title="Sub-Material (부자재)">SM</button>
<button type="button" data-type="RM" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-orange-50" title="Raw Material (원자재)">RM</button>
<button type="button" data-type="CS" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200" title="Cost (비용항목)">CS</button>
</div>
</div>
<!-- 품목 리스트 (HTMX) -->
<div id="item-list"
class="flex-1 overflow-y-auto"
hx-get="/api/admin/items?per_page=50"
hx-trigger="load"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
<div class="flex justify-center items-center p-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
</div>
<!-- 중앙 패널: BOM 트리 + 수식 산출 -->
<div class="flex-1 bg-white rounded-lg shadow-sm flex flex-col overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200">
<div class="flex items-center gap-1">
<button type="button" id="tab-bom"
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-blue-100 text-blue-800"
onclick="switchBomTab('bom')">
BOM
</button>
<button type="button" id="tab-formula-bom"
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200"
onclick="switchBomTab('formula')"
style="display:none;">
수식 산출
</button>
</div>
</div>
<!-- 수식 산출 입력 (가변사이즈 품목 선택 시에만 표시) -->
<div id="formula-input-panel" style="display:none;" class="p-4 bg-gray-50 border-b border-gray-200">
<div class="flex items-end gap-3 flex-wrap">
<div>
<label class="block text-xs text-gray-500 mb-1"> W (mm)</label>
<input type="number" id="input-width" value="3000" min="100" max="10000" step="1"
class="w-24 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">높이 H (mm)</label>
<input type="number" id="input-height" value="3000" min="100" max="10000" step="1"
class="w-24 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">수량</label>
<input type="number" id="input-qty" value="1" min="1" max="100" step="1"
class="w-16 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">전원</label>
<select id="input-mp"
class="px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
<option value="single">단상(220V)</option>
<option value="three">삼상(380V)</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">제품모델</label>
<select id="input-product-model"
class="px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
<option value="KSS01">KSS01</option>
<option value="KSS02">KSS02</option>
<option value="KSE01">KSE01</option>
<option value="KWE01">KWE01</option>
<option value="KTE01">KTE01</option>
<option value="KQTS01">KQTS01</option>
<option value="KDSS01">KDSS01</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">설치타입</label>
<select id="input-installation-type"
class="px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
<option value="벽면형">벽면형</option>
<option value="측면형">측면형</option>
<option value="혼합형">혼합형</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">마감</label>
<select id="input-finishing-type"
class="px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
<option value="SUS">SUS</option>
<option value="EGI">EGI</option>
</select>
</div>
<button type="button" id="btn-calculate" onclick="calculateFormula()"
class="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors">
산출
</button>
</div>
</div>
<!-- BOM 트리 영역 -->
<div id="bom-tree-container" class="flex-1 overflow-y-auto p-4">
<div class="text-center py-10">
<svg class="w-10 h-10 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="1.5" d="M4 6h16M4 12h16M4 18h7"/>
</svg>
<p class="text-gray-400 text-sm">좌측에서 품목을 선택하면<br>BOM 트리가 표시됩니다.</p>
</div>
</div>
<!-- 수식 산출 결과 영역 (초기 숨김) -->
<div id="formula-result-container" class="flex-1 overflow-y-auto p-4" style="display:none;">
<p class="text-gray-400 text-center py-10">오픈사이즈를 입력하고 산출 버튼을 클릭하세요.</p>
</div>
</div>
<!-- 우측 패널: 품목 상세 -->
<div class="w-96 flex-shrink-0 bg-white rounded-lg shadow-sm flex flex-col overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-700">품목 상세</h2>
</div>
<div id="item-detail" class="flex-1 overflow-y-auto p-4">
<p class="text-gray-400 text-center py-10">품목을 선택하면 상세정보가 표시됩니다.</p>
</div>
</div>
</div>
{{-- 이력 조회 모달 --}}
<div id="history-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()">
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200">
<h3 class="text-base font-semibold text-gray-800">품목 이력</h3>
<button onclick="closeHistoryModal()" class="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
<div id="history-modal-body" class="p-5 min-h-[200px]"></div>
<div class="px-5 py-3 border-t border-gray-100 text-right">
<button onclick="closeHistoryModal()" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">닫기</button>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
(function() {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
let searchTimer = null;
let currentTypeFilter = '';
let currentBomTab = 'bom';
let currentItemId = null;
let currentItemCode = null;
let skipCenterUpdate = false;
// ── 좌측 검색 (debounce 300ms) ──
const searchInput = document.getElementById('item-search');
searchInput.addEventListener('input', function() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
loadItemList();
}, 300);
});
// ── 유형 필터 ──
document.querySelectorAll('.item-type-filter').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.item-type-filter').forEach(b => {
b.classList.remove('active', 'bg-blue-100', 'text-blue-800', 'font-medium');
b.classList.add('bg-gray-100', 'text-gray-600');
});
this.classList.add('active', 'bg-blue-100', 'text-blue-800', 'font-medium');
this.classList.remove('bg-gray-100', 'text-gray-600');
currentTypeFilter = this.dataset.type;
loadItemList();
});
});
// ── 품목 리스트 로드 ──
function loadItemList() {
const search = searchInput.value.trim();
let url = `/api/admin/items?per_page=50`;
if (search) url += `&search=${encodeURIComponent(search)}`;
if (currentTypeFilter) url += `&item_type=${currentTypeFilter}`;
htmx.ajax('GET', url, {
target: '#item-list',
swap: 'innerHTML',
headers: {'X-CSRF-TOKEN': csrfToken}
});
}
// ── 품목 선택 (좌측/중앙 공용) ──
window.selectItem = function(itemId, updateTree) {
if (typeof updateTree === 'undefined') updateTree = true;
// 좌측 하이라이트
document.querySelectorAll('.item-row').forEach(el => {
el.classList.remove('bg-blue-50', 'border-l-4', 'border-blue-500');
});
const selected = document.querySelector(`[data-item-id="${itemId}"]`);
if (selected) {
selected.classList.add('bg-blue-50', 'border-l-4', 'border-blue-500');
}
// 중앙 트리 갱신 (좌측에서 클릭 시에만)
if (updateTree) {
const treeContainer = document.getElementById('bom-tree-container');
treeContainer.innerHTML = '<div class="flex justify-center py-10"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
fetch(`/api/admin/items/${itemId}/bom-tree`, {
headers: {'X-CSRF-TOKEN': csrfToken}
})
.then(res => res.json())
.then(tree => {
treeContainer.innerHTML = '';
if (tree.has_children) {
const ul = document.createElement('ul');
ul.className = 'text-sm space-y-0.5';
renderBomTree(tree, ul, true);
treeContainer.appendChild(ul);
} else {
treeContainer.innerHTML = '<p class="text-gray-400 text-center py-10">BOM 구성이 없습니다.</p>';
}
})
.catch(() => {
treeContainer.innerHTML = '<p class="text-red-400 text-center py-10">BOM 로드 실패</p>';
});
}
// 우측 상세 갱신 (항상)
htmx.ajax('GET', `/api/admin/items/${itemId}/detail`, {
target: '#item-detail',
swap: 'innerHTML',
headers: {'X-CSRF-TOKEN': csrfToken}
});
};
// 중앙 트리 노드 클릭 (트리 유지, 우측만 갱신)
window.selectTreeNode = function(itemId) {
selectItem(itemId, false);
};
// ── BOM 트리 렌더링 (3단계: FG → 카테고리 → PT) ──
const chevronDown = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>';
const chevronRight = '<svg class="w-3 h-3" 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>';
function renderBomTree(node, container, isRoot) {
const li = document.createElement('li');
const isCategory = node.item_type === 'CAT';
const isBending = node.code && node.code.startsWith('SF-BND');
const row = document.createElement('div');
if (isCategory) {
// 카테고리 노드 (주자재, 모터, 절곡품 등)
row.className = 'flex items-center gap-1.5 py-1.5 px-2 rounded bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors';
} else if (isRoot) {
row.className = 'flex items-center gap-1.5 py-1.5 px-1 rounded cursor-pointer hover:bg-gray-100 transition-colors group';
} else {
row.className = 'flex items-center gap-1.5 py-1 px-1 rounded cursor-pointer hover:bg-gray-100 transition-colors group';
if (isBending) row.classList.add('bg-amber-50');
}
if (!isCategory) row.onclick = () => selectTreeNode(node.id);
// 토글 아이콘
let childUl = null;
const toggleBtn = document.createElement('span');
toggleBtn.className = 'w-4 h-4 flex items-center justify-center text-gray-400 flex-shrink-0';
if (node.has_children) {
toggleBtn.innerHTML = chevronDown;
toggleBtn.style.cursor = 'pointer';
toggleBtn.onclick = function(e) {
e.stopPropagation();
if (childUl.style.display === 'none') {
childUl.style.display = '';
toggleBtn.innerHTML = chevronDown;
} else {
childUl.style.display = 'none';
toggleBtn.innerHTML = chevronRight;
}
};
}
row.appendChild(toggleBtn);
if (isCategory) {
// 카테고리: 폴더 아이콘 + 이름 + 건수
const nameSpan = document.createElement('span');
nameSpan.className = 'text-sm font-semibold text-gray-700';
nameSpan.textContent = node.name;
row.appendChild(nameSpan);
const countSpan = document.createElement('span');
countSpan.className = 'text-xs text-gray-400';
countSpan.textContent = `(${node.count || node.children?.length || 0}건)`;
row.appendChild(countSpan);
} else {
// 품목: 뱃지 + 이름 + 코드 + 수량
const badge = document.createElement('span');
badge.className = getTypeBadgeClass(node.item_type);
badge.textContent = node.item_type;
badge.title = getTypeTitle(node.item_type);
row.appendChild(badge);
const nameSpan = document.createElement('span');
nameSpan.className = isRoot ? 'text-sm font-semibold text-gray-800 truncate' : 'text-sm text-gray-700 truncate';
nameSpan.textContent = node.name;
row.appendChild(nameSpan);
if (node.code) {
const codeSpan = document.createElement('span');
codeSpan.className = 'font-mono text-xs text-gray-400 truncate hidden group-hover:inline';
codeSpan.textContent = node.code;
row.appendChild(codeSpan);
}
if (node.quantity) {
const qtySpan = document.createElement('span');
qtySpan.className = 'ml-auto text-xs font-medium text-blue-600 flex-shrink-0';
qtySpan.textContent = 'x' + node.quantity;
row.appendChild(qtySpan);
}
}
li.appendChild(row);
// 자식 노드
if (node.children && node.children.length > 0) {
childUl = document.createElement('ul');
childUl.className = isCategory ? 'ml-4 pl-2 border-l-2 border-gray-300' : 'ml-4 pl-2 border-l border-gray-200';
node.children.forEach(child => renderBomTree(child, childUl, false));
li.appendChild(childUl);
}
container.appendChild(li);
}
// ── 유형별 뱃지 색상 ──
const typeLabels = {
'FG': 'Finished Goods (완제품)',
'PT': 'Parts (반제품/부품)',
'SF': 'Semi-Finished (반제품)',
'SM': 'Sub-Material (부자재)',
'RM': 'Raw Material (원자재)',
'CS': 'Cost (비용항목)',
};
function getTypeBadgeClass(type) {
const colors = {
'FG': 'bg-blue-100 text-blue-800',
'PT': 'bg-green-100 text-green-800',
'SF': 'bg-amber-100 text-amber-800',
'SM': 'bg-yellow-100 text-yellow-800',
'RM': 'bg-orange-100 text-orange-800',
'CS': 'bg-gray-100 text-gray-800',
};
return `inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${colors[type] || 'bg-gray-100 text-gray-800'}`;
}
function getTypeTitle(type) {
return typeLabels[type] || type;
}
// 전역에서 사용하도록 노출
window.getTypeBadgeClass = getTypeBadgeClass;
// ── 탭 전환 ──
window.switchBomTab = function(tab) {
currentBomTab = tab;
// 탭 버튼 스타일
document.querySelectorAll('.bom-tab').forEach(btn => {
btn.classList.remove('bg-blue-100', 'text-blue-800');
btn.classList.add('bg-gray-100', 'text-gray-600');
});
const tabBtnMap = { bom: 'tab-bom', formula: 'tab-formula-bom' };
const activeBtn = document.getElementById(tabBtnMap[tab]);
if (activeBtn) {
activeBtn.classList.remove('bg-gray-100', 'text-gray-600');
activeBtn.classList.add('bg-blue-100', 'text-blue-800');
}
// 콘텐츠 영역 전환
document.getElementById('bom-tree-container').style.display = (tab === 'bom') ? '' : 'none';
document.getElementById('formula-input-panel').style.display = (tab === 'formula') ? '' : 'none';
document.getElementById('formula-result-container').style.display = (tab === 'formula') ? '' : 'none';
};
// ── 가변사이즈 탭 표시/숨김 ──
function showFormulaTab() {
document.getElementById('tab-formula-bom').style.display = '';
}
function hideFormulaTab() {
document.getElementById('tab-formula-bom').style.display = 'none';
document.getElementById('formula-input-panel').style.display = 'none';
document.getElementById('formula-result-container').style.display = 'none';
if (currentBomTab === 'formula') switchBomTab('bom');
}
// ── FG 코드 파싱 → 입력폼 자동 세팅 ──
// FG-KQTS01-벽면형-SUS → { model: 'KQTS01', installation: '벽면형', finishing: 'SUS' }
// 파싱 실패 시 기본값 유지 (추후 FG 코드 형식 변경 대비)
function parseFgCode(code) {
if (!code) return null;
const parts = code.split('-');
// FG-{MODEL}-{INSTALLATION}-{FINISHING} 형식인 경우만 파싱
if (parts.length >= 4 && parts[0] === 'FG') {
return { model: parts[1], installation: parts[2], finishing: parts[3] };
}
return null;
}
function setSelectValue(selectId, value) {
const el = document.getElementById(selectId);
if (!el) return;
for (let i = 0; i < el.options.length; i++) {
if (el.options[i].value === value) {
el.selectedIndex = i;
return;
}
}
}
// ── 상세 로드 완료 후 FG 품목 감지 → 수식 산출 탭 표시 ──
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'item-detail') {
// 산출 결과 아이템 클릭 시에는 중앙 패널 유지
if (skipCenterUpdate) {
skipCenterUpdate = false;
return;
}
const meta = document.getElementById('item-meta-data');
if (meta) {
currentItemId = meta.dataset.itemId;
currentItemCode = meta.dataset.itemCode;
if (meta.dataset.itemType === 'FG') {
// FG 코드에서 모델/설치타입/마감타입 파싱하여 입력폼 자동 세팅
const parsed = parseFgCode(currentItemCode);
if (parsed) {
setSelectValue('input-product-model', parsed.model);
setSelectValue('input-installation-type', parsed.installation);
setSelectValue('input-finishing-type', parsed.finishing);
}
showFormulaTab();
} else {
hideFormulaTab();
}
}
}
});
// ── 수식 산출 API 호출 ──
window.calculateFormula = function() {
if (!currentItemId) return;
const width = parseInt(document.getElementById('input-width').value) || 3000;
const height = parseInt(document.getElementById('input-height').value) || 3000;
const qty = parseInt(document.getElementById('input-qty').value) || 1;
const mp = document.getElementById('input-mp').value || 'single';
const productModel = document.getElementById('input-product-model').value || 'KSS01';
const installationType = document.getElementById('input-installation-type').value || '벽면형';
const finishingType = document.getElementById('input-finishing-type').value || 'SUS';
if (width < 100 || width > 10000 || height < 100 || height > 10000) {
alert('폭과 높이는 100~10000 범위로 입력하세요.');
return;
}
const container = document.getElementById('formula-result-container');
container.innerHTML = '<div class="flex justify-center py-10"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
fetch(`/api/admin/items/${currentItemId}/calculate-formula`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({ width, height, qty, mp, product_model: productModel, installation_type: installationType, finishing_type: finishingType }),
})
.then(res => {
if (!res.ok) {
return res.text().then(text => {
let msg = `HTTP ${res.status}`;
try { const j = JSON.parse(text); msg = j.error || j.message || msg; } catch(e) {}
throw new Error(msg);
});
}
return res.json();
})
.then(data => {
if (data.success === false) {
container.innerHTML = `
<div class="text-center py-10">
<p class="text-red-500 text-sm mb-2">${data.error || '산출 실패'}</p>
<button onclick="calculateFormula()" class="text-blue-600 text-sm hover:underline">다시 시도</button>
</div>`;
return;
}
renderFormulaTree(data, container);
})
.catch(err => {
console.error('calculateFormula error:', err);
container.innerHTML = `
<div class="text-center py-10">
<p class="text-red-500 text-sm mb-2">${err.message || '서버 연결 실패'}</p>
<button onclick="calculateFormula()" class="text-blue-600 text-sm hover:underline">다시 시도</button>
</div>`;
});
};
// ── 수식 산출 결과 → BOM 저장 ──
let lastFormulaItems = []; // 마지막 산출 결과 보관
window.saveFormulaBom = function() {
if (!currentItemId || lastFormulaItems.length === 0) return;
if (!confirm(`산출된 ${lastFormulaItems.length}건의 품목을 BOM으로 저장하시겠습니까?`)) return;
fetch(`/api/admin/items/${currentItemId}/save-bom`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({ bom_items: lastFormulaItems }),
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert(data.message);
// BOM 탭으로 전환하여 트리 확인
switchBomTab('bom');
selectItem(currentItemId, true);
} else {
alert(data.error || 'BOM 저장 실패');
}
})
.catch(err => {
console.error('saveBom error:', err);
alert('BOM 저장 중 오류가 발생했습니다.');
});
};
// ── 수식 산출 결과 트리 렌더링 ──
function renderFormulaTree(data, container) {
container.innerHTML = '';
const groupedItems = data.grouped_items || {};
// 산출 결과 품목을 BOM 저장용으로 수집
lastFormulaItems = [];
Object.values(groupedItems).forEach(group => {
(group.items || []).forEach(item => {
if (item.item_id) {
lastFormulaItems.push({
child_item_id: item.item_id,
child_item_code: item.item_code || '',
quantity: item.quantity || 1,
unit: item.unit || '',
category: group.name || '',
});
}
});
});
// 합계 + BOM 저장 버튼
if (data.grand_total !== undefined) {
const totalDiv = document.createElement('div');
totalDiv.className = 'mb-4 p-3 bg-blue-50 rounded-lg flex justify-between items-center';
totalDiv.innerHTML = `
<span class="text-sm font-medium text-blue-800">
${data.finished_goods?.name || ''} (${data.finished_goods?.code || ''})
<span class="text-xs text-blue-600 ml-2">W:${data.variables?.W0} H:${data.variables?.H0}</span>
</span>
<span class="flex items-center gap-3">
<span class="text-sm font-bold text-blue-900">합계: ${Number(data.grand_total).toLocaleString()}원</span>
<button onclick="saveFormulaBom()" class="px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700 transition-colors">BOM 저장</button>
</span>
`;
container.appendChild(totalDiv);
}
// 카테고리 그룹별 렌더링
Object.entries(groupedItems).forEach(([groupKey, group]) => {
// group = { name: '강재', items: [...], subtotal: 12345 }
const groupItems = group.items || [];
if (groupItems.length === 0) return;
const groupDiv = document.createElement('div');
groupDiv.className = 'mb-3';
const groupName = group.name || groupKey;
const subtotal = group.subtotal || 0;
// 그룹 헤더
const header = document.createElement('div');
header.className = 'flex items-center gap-2 py-2 px-3 bg-gray-50 rounded-t-lg cursor-pointer';
header.innerHTML = `
<span class="toggle-icon text-xs text-gray-400">▼</span>
<span>📦</span>
<span class="text-sm font-semibold text-gray-700">${groupName}</span>
<span class="text-xs text-gray-500">(${groupItems.length}건)</span>
<span class="ml-auto text-xs font-medium text-gray-600">소계: ${Number(subtotal).toLocaleString()}원</span>
`;
const listDiv = document.createElement('div');
listDiv.className = 'border border-gray-100 rounded-b-lg divide-y divide-gray-50';
// 그룹 접기/펼치기
header.onclick = function() {
const toggle = header.querySelector('.toggle-icon');
if (listDiv.style.display === 'none') {
listDiv.style.display = '';
toggle.textContent = '▼';
} else {
listDiv.style.display = 'none';
toggle.textContent = '▶';
}
};
// 아이템 목록
groupItems.forEach(item => {
const row = document.createElement('div');
row.className = 'flex items-center gap-2 py-1.5 px-3 hover:bg-gray-50 cursor-pointer text-sm';
const itemType = item.item_type || 'PT';
row.innerHTML = `
<span class="${getTypeBadgeClass(itemType)}" title="${getTypeTitle(itemType)}">
${itemType}
</span>
<span class="font-mono text-xs text-gray-500 w-32 truncate">${item.item_code || ''}</span>
<span class="text-gray-700 flex-1 truncate">${item.item_name || ''}</span>
<span class="text-xs text-gray-500 w-16 text-right">${item.quantity || 0} ${item.unit || ''}</span>
<span class="text-xs text-blue-600 font-medium w-20 text-right">${Number(item.total_price || 0).toLocaleString()}원</span>
`;
if (item.item_id) {
row.onclick = () => {
// 활성 행 표시
container.querySelectorAll('.formula-row-active').forEach(el => {
el.classList.remove('formula-row-active', 'ring-1', 'ring-blue-300');
});
row.classList.add('formula-row-active', 'ring-1', 'ring-blue-300');
// 우측 상세만 갱신, 중앙 패널 유지
skipCenterUpdate = true;
htmx.ajax('GET', `/api/admin/items/${item.item_id}/detail`, {
target: '#item-detail',
swap: 'innerHTML',
headers: {'X-CSRF-TOKEN': csrfToken}
});
};
}
listDiv.appendChild(row);
});
groupDiv.appendChild(header);
groupDiv.appendChild(listDiv);
container.appendChild(groupDiv);
});
if (Object.keys(groupedItems).length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-10">산출된 자재가 없습니다.</p>';
}
}
// ── 품목 삭제 ──
window.confirmDeleteItem = function(itemId, itemName) {
if (!confirm(`"${itemName}" 품목을 삭제하시겠습니까?\n\n※ 다른 곳에서 사용 중인 품목은 삭제할 수 없습니다.`)) return;
fetch(`/api/admin/items/${itemId}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert(data.message);
// 상세 패널 초기화 + 목록 새로고침
document.getElementById('item-detail').innerHTML = '<p class="text-gray-400 text-sm text-center py-10">품목을 선택하세요</p>';
document.getElementById('bom-tree-container').innerHTML = '';
loadItemList();
} else {
let msg = data.error || '삭제 실패';
if (data.usage && data.usage.length > 0) {
msg += '\n\n참조 현황:\n- ' + data.usage.join('\n- ');
}
alert(msg);
}
})
.catch(err => {
console.error('deleteItem error:', err);
alert('삭제 요청 중 오류가 발생했습니다.');
});
};
// ── 품목 이력 조회 ──
window.showItemHistory = function(itemId) {
const modal = document.getElementById('history-modal');
const body = document.getElementById('history-modal-body');
body.innerHTML = '<div class="flex justify-center py-10"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
modal.classList.remove('hidden');
fetch(`/api/admin/items/${itemId}/history`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(res => res.json())
.then(data => {
let html = '';
// 기본 정보
html += `<div class="mb-4 px-4 py-3 bg-gray-50 rounded-lg text-sm">
<div class="flex gap-4">
<span class="text-gray-500">코드</span>
<span class="font-mono font-medium">${data.item.code}</span>
</div>
<div class="flex gap-4 mt-1">
<span class="text-gray-500">생성일</span>
<span>${data.item.created_at || '-'}</span>
<span class="text-gray-500 ml-4">최종수정</span>
<span>${data.item.updated_at || '-'}</span>
</div>
</div>`;
// 이력 목록
if (data.logs.length === 0) {
html += '<p class="text-gray-400 text-sm text-center py-6">기록된 이력이 없습니다.</p>';
} else {
html += '<div class="space-y-2 max-h-96 overflow-y-auto">';
data.logs.forEach(log => {
const actionColors = {
'생성': 'bg-green-100 text-green-800',
'수정': 'bg-blue-100 text-blue-800',
'삭제': 'bg-red-100 text-red-800',
'BOM 변경': 'bg-purple-100 text-purple-800',
'재고 증가': 'bg-teal-100 text-teal-800',
'재고 차감': 'bg-orange-100 text-orange-800',
};
const color = actionColors[log.action_label] || 'bg-gray-100 text-gray-800';
html += `<div class="px-3 py-2 border border-gray-100 rounded-lg">
<div class="flex items-center gap-2 text-sm">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${color}">${log.action_label}</span>
<span class="text-gray-500 text-xs">${log.created_at}</span>
<span class="text-gray-700 text-xs ml-auto">${log.actor}</span>
</div>`;
// 변경 내용 요약 (before/after 비교)
if (log.action === 'updated' && log.before && log.after) {
const changes = [];
for (const key of Object.keys(log.after)) {
if (key === 'updated_at' || key === 'updated_by') continue;
const bVal = log.before[key];
const aVal = log.after[key];
if (JSON.stringify(bVal) !== JSON.stringify(aVal)) {
if (typeof aVal === 'object') continue; // JSON 필드는 건너뜀
changes.push(key);
}
}
if (changes.length > 0) {
html += `<div class="mt-1 text-xs text-gray-500">변경 필드: ${changes.join(', ')}</div>`;
}
}
html += '</div>';
});
html += '</div>';
}
body.innerHTML = html;
})
.catch(err => {
console.error('history error:', err);
body.innerHTML = '<p class="text-red-500 text-sm text-center py-6">이력 조회에 실패했습니다.</p>';
});
};
window.closeHistoryModal = function() {
document.getElementById('history-modal').classList.add('hidden');
};
})();
</script>
@endpush