Files
sam-api/public/tenant/product/bom_editor.php

296 lines
14 KiB
PHP
Raw Normal View History

2025-08-10 02:36:50 +09:00
<?php
$CURRENT_SECTION = 'item'; // 상단 네비 하이라이트 용도
include '../inc/header.php';
2025-08-10 02:36:50 +09:00
?>
<div class="container" style="max-width:1280px; margin-top:24px;">
<!-- 상단 -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link" href="/tenant/material/list.php">자재 관리</a></li>
<li class="nav-item"><a class="nav-link" href="/tenant/product/model_list.php">모델 관리</a></li>
<li class="nav-item"><a class="nav-link active" href="/tenant/product/bom_editor.php">BOM 관리</a></li>
</ul>
<div class="row g-3">
<!-- 좌측: 모델 + 자재리스트 -->
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<strong>제품(모델) 선택</strong>
<div class="d-flex gap-2">
<select id="modelFilter" class="form-select form-select-sm" style="width:120px;">
<option value="">전체</option>
<option value="인정">인정</option>
<option value="비인정">비인정</option>
</select>
<input id="modelSearch" class="form-control form-control-sm" placeholder="검색">
</div>
</div>
<div class="list-group" id="modelList" style="max-height:240px; overflow:auto;"></div>
</div>
<div class="card">
<div class="card-header"><strong>자재/부품(해당 제품용)</strong></div>
<div class="card-body p-0">
<div style="max-height:340px; overflow:auto;">
<table class="table table-sm m-0">
<thead class="table-light">
<tr><th style="width:60px;">분류</th><th>이름</th><th style="width:70px;">단위</th></tr>
</thead>
<tbody id="materialList"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 우측: 트리 + 요약 -->
<div class="col-md-8">
<div class="card mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<div>
<strong>편집 트리</strong>
<small class="text-muted ms-2">(루트=선택한 제품)</small>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" id="btnExpand">모두 펼치기</button>
<button class="btn btn-sm btn-outline-secondary" id="btnCollapse">모두 닫기</button>
</div>
</div>
<div class="card-body">
<div id="treeRoot"></div>
</div>
<div class="card-footer d-flex justify-content-between">
<div class="small text-muted">
<span class="me-3">합계수량(샘플): <b id="sumQty">0</b></span>
<span class="me-3">노드수: <b id="sumNodes">0</b></span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" id="btnPreview">JSON 미리보기</button>
<button class="btn btn-primary btn-sm" id="btnSave">저장</button>
</div>
</div>
</div>
<textarea id="jsonPreview" class="form-control" rows="6" style="display:none;"></textarea>
</div>
</div>
</div>
<style>
.node { border:1px solid #e5e7eb; border-radius:10px; padding:.6rem .75rem; margin-bottom:.5rem; }
.node-header{display:flex; align-items:center; gap:.5rem}
.node-children{margin-left:1.25rem; margin-top:.5rem}
.badge-type{font-size:.7rem}
.node-tools .btn{ --bs-btn-padding-y: .1rem; --bs-btn-padding-x: .35rem; --bs-btn-font-size: .75rem; }
</style>
<script>
(function($){
// ===== 샘플 데이터 =====
const MODELS = [
{id:201, name:'KSS01', status:'인정'},
{id:202, name:'KSS02', status:'비인정'},
{id:203, name:'KSE01', status:'인정'},
{id:204, name:'KWE01', status:'비인정'},
];
const MATERIALS = [
{id:1, type:'material', category:'자재', name:'EGI 1.5T', unit:'EA'},
{id:2, type:'material', category:'자재', name:'SUS 1.2T', unit:'EA'},
{id:3, type:'part', category:'부품', name:'가이드레일', unit:'M'},
{id:4, type:'part', category:'부품', name:'모터', unit:'EA'},
];
// 트리 데이터 예시(모델별)
const TREE_BY_MODEL = {
201: { id:'root-201', type:'bom', name:'KSS01', qty:1, unit:'EA', note:'',
children:[
{id:'b-10',type:'bom', name:'셔터박스', qty:1, unit:'EA', note:'', children:[
{id:'m-1', type:'material', ref:1, name:'EGI 1.5T', qty:2, unit:'EA', note:''}
]},
{id:'p-3', type:'part', ref:3, name:'가이드레일', qty:3, unit:'M', note:''}
]
},
202: { id:'root-202', type:'bom', name:'KSS02', qty:1, unit:'EA', note:'', children:[] },
203: { id:'root-203', type:'bom', name:'KSE01', qty:1, unit:'EA', note:'', children:[] },
204: { id:'root-204', type:'bom', name:'KWE01', qty:1, unit:'EA', note:'', children:[] },
};
// ===== 좌측 패널 렌더 =====
function renderModels(){
const f = $('#modelFilter').val();
const kw = ($('#modelSearch').val()||'').toLowerCase();
const list = MODELS.filter(m=>{
const ok1 = !f || m.status===f;
const ok2 = !kw || m.name.toLowerCase().includes(kw);
return ok1 && ok2;
});
const html = list.map(m =>
`<a href="#" class="list-group-item list-group-item-action model-item" data-id="${m.id}">
<div class="d-flex justify-content-between">
<span>${m.name}</span>
<span class="badge ${m.status==='인정'?'bg-primary':'bg-secondary'}">${m.status}</span>
</div>
</a>`
).join('');
$('#modelList').html(html);
}
function renderMaterials(){
const rows = MATERIALS.map(x=> `<tr>
<td>${x.category}</td><td>${x.name}</td><td>${x.unit}</td></tr>`).join('');
$('#materialList').html(rows);
}
// ===== 트리 렌더 =====
function nodeHeader(n){
const label = n.type==='bom' ? n.name
: (n.name || (n.type==='material'?'(자재)':'(부품)'));
const badge = n.type==='bom' ? 'BOM' : (n.type==='material'?'MAT':'PART');
const refInfo = n.ref ? `<span class="text-muted small ms-1">#${n.ref}</span>` : '';
return `
<div class="node-header">
<span class="badge rounded-pill bg-info-subtle text-dark border badge-type">${badge}</span>
<strong>${label}</strong>${refInfo}
<div class="ms-auto node-tools">
<button class="btn btn-light btn-sm btnAdd" data-kind="bom" title="BOM 추가">+B</button>
<button class="btn btn-light btn-sm btnAdd" data-kind="material" title="자재 추가">+M</button>
<button class="btn btn-light btn-sm btnAdd" data-kind="part" title="부품 추가">+P</button>
<button class="btn btn-outline-danger btn-sm btnDel" title="삭제"></button>
</div>
</div>
<div class="row g-2 mt-2">
<div class="col-3"><input class="form-control form-control-sm inpQty" type="number" min="0" value="${n.qty||1}" placeholder="수량"></div>
<div class="col-2"><input class="form-control form-control-sm inpUnit" value="${n.unit||''}" placeholder="단위"></div>
<div class="col"><input class="form-control form-control-sm inpNote" value="${n.note||''}" placeholder="메모"></div>
</div>
`;
}
function renderNode(n){
const hasChildren = Array.isArray(n.children) && n.children.length;
const childHtml = hasChildren ? n.children.map(ch => renderNode(ch)).join('') : '';
return `
<div class="node" data-id="${n.id}">
${nodeHeader(n)}
<div class="node-children">${childHtml}</div>
</div>
`;
}
function bindNodeEvents(rootEl, data){
// 입력 변경 반영
rootEl.find('.inpQty').on('input', function(){
const id = $(this).closest('.node').data('id');
const node = findNodeById(data, id);
node.qty = +this.value || 0; computeSummary(data);
});
rootEl.find('.inpUnit').on('input', function(){
const id = $(this).closest('.node').data('id');
findNodeById(data, id).unit = this.value;
});
rootEl.find('.inpNote').on('input', function(){
const id = $(this).closest('.node').data('id');
findNodeById(data, id).note = this.value;
});
// 추가/삭제
rootEl.find('.btnAdd').on('click', function(){
const kind = $(this).data('kind'); // bom/material/part
const id = $(this).closest('.node').data('id');
const parent = findNodeById(data, id);
parent.children = parent.children || [];
const newId = 'n-' + Math.random().toString(36).slice(2,8);
const base = {id:newId, type:kind, qty:1, unit:'EA', note:'', children:[]};
if (kind!=='bom'){
// 참조 선택(샘플: 첫 항목 고정 or 프롬프트)
const ref = prompt(kind.toUpperCase()+' 참조 ID(예: 1):', '1');
base.ref = parseInt(ref,10)||1;
const refItem = MATERIALS.find(m=>m.id===base.ref) || {name:kind.toUpperCase()};
base.name = refItem.name;
base.unit = refItem.unit||'EA';
} else {
base.name = prompt('BOM 이름:', '서브어셈블리') || 'BOM';
}
parent.children.push(base);
drawTree(data);
});
rootEl.find('.btnDel').on('click', function(){
const id = $(this).closest('.node').data('id');
if (!confirm('삭제할까요?')) return;
removeNode(data, id);
drawTree(data);
});
}
function findNodeById(node, id){
if (node.id===id) return node;
if (!node.children) return null;
for (const ch of node.children){
const f = findNodeById(ch, id);
if (f) return f;
}
return null;
}
function removeNode(node, id){
if (!node.children) return false;
const idx = node.children.findIndex(c=>c.id===id);
if (idx>=0){ node.children.splice(idx,1); return true; }
return node.children.some(c=>removeNode(c,id));
}
function computeSummary(root){
let nodes=0, qty=0;
(function walk(n){
nodes++; qty += +(n.qty||0);
(n.children||[]).forEach(walk);
})(root);
$('#sumNodes').text(nodes);
$('#sumQty').text(qty);
}
// 그리기
let CURRENT_MODEL_ID = MODELS[0].id;
let CURRENT_TREE = JSON.parse(JSON.stringify(TREE_BY_MODEL[CURRENT_MODEL_ID]));
function drawTree(data){
$('#treeRoot').html( renderNode(data) );
bindNodeEvents($('#treeRoot'), data);
computeSummary(data);
}
// ===== 이벤트 =====
$('#modelFilter, #modelSearch').on('input', renderModels);
$('#modelList').on('click', '.model-item', function(e){
e.preventDefault();
const id = parseInt($(this).data('id'),10);
CURRENT_MODEL_ID = id;
CURRENT_TREE = JSON.parse(JSON.stringify(TREE_BY_MODEL[id] || {id:'root-'+id,type:'bom',name:'NEW',qty:1,unit:'EA',children:[]}));
drawTree(CURRENT_TREE);
$('#modelList .model-item').removeClass('active');
$(this).addClass('active');
});
$('#btnExpand').on('click', ()=> $('#treeRoot .node-children').show());
$('#btnCollapse').on('click', ()=> $('#treeRoot .node-children').hide());
$('#btnPreview').on('click', ()=>{
const txt = JSON.stringify(CURRENT_TREE, null, 2);
$('#jsonPreview').val(txt).show().focus();
});
$('#btnSave').on('click', ()=>{
const payload = {
model_id: CURRENT_MODEL_ID,
tree: CURRENT_TREE
};
console.log('[SAVE] payload', payload);
alert('저장(모의). 콘솔을 확인하세요.');
// 실제는 $.post('/tenant/api/product/save_bom.php', payload)
});
// 초기 렌더
renderModels(); renderMaterials();
$('#modelList .model-item').first().addClass('active');
drawTree(CURRENT_TREE);
})(jQuery);
</script>
<?php include '../inc/footer.php'; ?>