Files
sam-api/public/tenant/product/bom_editor.php
hskwon cc206fdbed style: Laravel Pint 코드 포맷팅 적용
- PSR-12 스타일 가이드 준수
- 302개 파일 스타일 이슈 자동 수정
- 코드 로직 변경 없음 (포맷팅만)
2025-11-06 17:45:49 +09:00

296 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?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'; ?>