296 lines
14 KiB
PHP
296 lines
14 KiB
PHP
<?php
|
||
$CURRENT_SECTION = 'item'; // 상단 네비 하이라이트 용도
|
||
include '../inc/header.php';
|
||
?>
|
||
<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'; ?>
|