feat: [item-management] 절곡BOM 탭 추가
- 중앙 패널에 '절곡 BOM' 탭 추가 (정적 BOM 옆) - SF-BND 절곡 품목과 하위 자재를 트리 구조로 표시 - 접힘/펼침 토글, 품목 클릭 시 우측 상세 갱신 - FG 품목 선택 시 해당 FG의 절곡 관련 BOM만 필터
This commit is contained in:
@@ -74,6 +74,18 @@ public function history(int $id): JsonResponse
|
||||
return response()->json($history);
|
||||
}
|
||||
|
||||
/**
|
||||
* 절곡BOM 트리 (JSON - 중앙 패널)
|
||||
* FG 품목 선택 시 해당 FG의 절곡 관련 BOM만, 미선택 시 전체 절곡 품목
|
||||
*/
|
||||
public function bendingBomTree(Request $request): JsonResponse
|
||||
{
|
||||
$itemId = $request->input('item_id') ? (int) $request->input('item_id') : null;
|
||||
$tree = $this->service->getBendingBomTree($itemId);
|
||||
|
||||
return response()->json($tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 기반 BOM 산출 (API 서버의 FormulaEvaluatorService HTTP 호출)
|
||||
*/
|
||||
|
||||
@@ -229,6 +229,127 @@ private function getActionLabel(string $action): string
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 절곡BOM 트리 조회 (SF-BND 품목 기반)
|
||||
* FG 품목 선택 시: 해당 FG의 BOM에서 절곡 관련 품목만 필터
|
||||
* 그 외: 전체 절곡 품목(SF-BND) 트리 반환
|
||||
*/
|
||||
public function getBendingBomTree(?int $itemId = null): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
// FG 품목이 선택된 경우: 해당 FG의 BOM에서 절곡 관련만 추출
|
||||
if ($itemId) {
|
||||
$item = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($itemId);
|
||||
|
||||
if ($item && $item->item_type === 'FG') {
|
||||
return $this->buildFgBendingTree($item, $tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 절곡 품목 트리
|
||||
$bendingItems = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', 'like', 'SF-BND%')
|
||||
->active()
|
||||
->orderBy('code')
|
||||
->get();
|
||||
|
||||
$nodes = [];
|
||||
foreach ($bendingItems as $bendingItem) {
|
||||
$nodes[] = $this->buildBomNode($bendingItem, 0, 3, []);
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* FG 품목의 BOM에서 절곡 관련 품목만 추출하여 트리 구성
|
||||
*/
|
||||
private function buildFgBendingTree(Item $fgItem, int $tenantId): array
|
||||
{
|
||||
$bomData = $fgItem->bom ?? [];
|
||||
if (empty($bomData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$childIds = array_column($bomData, 'child_item_id');
|
||||
$children = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $childIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$nodes = [];
|
||||
foreach ($bomData as $bom) {
|
||||
$child = $children->get($bom['child_item_id']);
|
||||
if (! $child) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 절곡 관련 품목이면 바로 추가
|
||||
if (str_starts_with($child->code, 'SF-BND')) {
|
||||
$node = $this->buildBomNode($child, 0, 3, []);
|
||||
$node['quantity'] = $bom['quantity'] ?? 1;
|
||||
$nodes[] = $node;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// SF 품목이면 그 하위에서 절곡 품목 탐색
|
||||
if ($child->item_type === 'SF' || $child->item_type === 'PT') {
|
||||
$subBending = $this->findBendingChildren($child, $tenantId, 1, []);
|
||||
$nodes = array_merge($nodes, $subBending);
|
||||
}
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재귀적으로 절곡 관련 자식 품목 탐색
|
||||
*/
|
||||
private function findBendingChildren(Item $item, int $tenantId, int $depth, array $visited): array
|
||||
{
|
||||
if (in_array($item->id, $visited) || $depth >= 5) {
|
||||
return [];
|
||||
}
|
||||
$visited[] = $item->id;
|
||||
|
||||
$bomData = $item->bom ?? [];
|
||||
if (empty($bomData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$childIds = array_column($bomData, 'child_item_id');
|
||||
$children = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $childIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$nodes = [];
|
||||
foreach ($bomData as $bom) {
|
||||
$child = $children->get($bom['child_item_id']);
|
||||
if (! $child) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($child->code, 'SF-BND')) {
|
||||
$node = $this->buildBomNode($child, 0, 3, []);
|
||||
$node['quantity'] = $bom['quantity'] ?? 1;
|
||||
$nodes[] = $node;
|
||||
} elseif ($child->item_type === 'SF' || $child->item_type === 'PT') {
|
||||
$subNodes = $this->findBendingChildren($child, $tenantId, $depth + 1, $visited);
|
||||
$nodes = array_merge($nodes, $subNodes);
|
||||
}
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
|
||||
// ── Private ──
|
||||
|
||||
private function buildBomNode(Item $item, int $depth, int $maxDepth, array $visited): array
|
||||
|
||||
@@ -50,6 +50,11 @@ class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-blue-100 text-blue-
|
||||
onclick="switchBomTab('static')">
|
||||
정적 BOM
|
||||
</button>
|
||||
<button type="button" id="tab-bending-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('bending')">
|
||||
절곡 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')"
|
||||
@@ -127,6 +132,11 @@ class="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-b
|
||||
<p class="text-gray-400 text-center py-10">좌측에서 품목을 선택하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 절곡 BOM 트리 영역 (초기 숨김) -->
|
||||
<div id="bending-bom-container" class="flex-1 overflow-y-auto p-4" style="display:none;">
|
||||
<p class="text-gray-400 text-center py-10">절곡 BOM을 로드 중...</p>
|
||||
</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>
|
||||
@@ -366,7 +376,8 @@ function getTypeTitle(type) {
|
||||
btn.classList.remove('bg-blue-100', 'text-blue-800');
|
||||
btn.classList.add('bg-gray-100', 'text-gray-600');
|
||||
});
|
||||
const activeBtn = document.getElementById(tab === 'static' ? 'tab-static-bom' : 'tab-formula-bom');
|
||||
const tabBtnMap = { static: 'tab-static-bom', bending: 'tab-bending-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');
|
||||
@@ -374,8 +385,14 @@ function getTypeTitle(type) {
|
||||
|
||||
// 콘텐츠 영역 전환
|
||||
document.getElementById('bom-tree-container').style.display = (tab === 'static') ? '' : 'none';
|
||||
document.getElementById('bending-bom-container').style.display = (tab === 'bending') ? '' : 'none';
|
||||
document.getElementById('formula-input-panel').style.display = (tab === 'formula') ? '' : 'none';
|
||||
document.getElementById('formula-result-container').style.display = (tab === 'formula') ? '' : 'none';
|
||||
|
||||
// 절곡BOM 탭 선택 시 데이터 로드
|
||||
if (tab === 'bending') {
|
||||
loadBendingBomTree();
|
||||
}
|
||||
};
|
||||
|
||||
// ── 가변사이즈 탭 표시/숨김 ──
|
||||
@@ -388,7 +405,124 @@ 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';
|
||||
switchBomTab('static');
|
||||
if (currentBomTab === 'formula') switchBomTab('static');
|
||||
}
|
||||
|
||||
// ── 절곡BOM 트리 로드 ──
|
||||
function loadBendingBomTree() {
|
||||
const container = document.getElementById('bending-bom-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>';
|
||||
|
||||
let url = '/api/admin/items/bending-bom-tree';
|
||||
if (currentItemId) {
|
||||
url += `?item_id=${currentItemId}`;
|
||||
}
|
||||
|
||||
fetch(url, { headers: {'X-CSRF-TOKEN': csrfToken} })
|
||||
.then(res => res.json())
|
||||
.then(nodes => {
|
||||
container.innerHTML = '';
|
||||
if (!nodes || nodes.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-400 text-center py-10">절곡 BOM 데이터가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 헤더 정보
|
||||
const header = document.createElement('div');
|
||||
header.className = 'mb-3 px-3 py-2 bg-amber-50 rounded-lg flex items-center gap-2';
|
||||
header.innerHTML = `
|
||||
<svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-amber-800">절곡 품목</span>
|
||||
<span class="text-xs text-amber-600">(${nodes.length}건)</span>
|
||||
`;
|
||||
container.appendChild(header);
|
||||
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'text-sm space-y-1';
|
||||
nodes.forEach(node => renderBendingNode(node, ul, 0));
|
||||
container.appendChild(ul);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('bendingBomTree error:', err);
|
||||
container.innerHTML = '<p class="text-red-400 text-center py-10">절곡 BOM 로드 실패</p>';
|
||||
});
|
||||
}
|
||||
|
||||
// ── 절곡BOM 노드 렌더링 (트리 구조, 접힘/펼침) ──
|
||||
function renderBendingNode(node, container, depth) {
|
||||
const li = document.createElement('li');
|
||||
if (depth > 0) li.className = 'ml-4';
|
||||
|
||||
const nodeEl = document.createElement('div');
|
||||
const depthColors = depth === 0
|
||||
? 'bg-amber-50 hover:bg-amber-100 border border-amber-200'
|
||||
: 'hover:bg-gray-50';
|
||||
nodeEl.className = `flex items-center gap-2 py-2 px-3 rounded cursor-pointer transition-colors ${depthColors}`;
|
||||
nodeEl.onclick = () => selectTreeNode(node.id);
|
||||
|
||||
// 펼침/접힘 토글
|
||||
let childList = null;
|
||||
if (node.has_children) {
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'text-gray-400 cursor-pointer select-none text-xs flex-shrink-0';
|
||||
toggle.textContent = '▼';
|
||||
toggle.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
if (childList.style.display === 'none') {
|
||||
childList.style.display = '';
|
||||
toggle.textContent = '▼';
|
||||
} else {
|
||||
childList.style.display = 'none';
|
||||
toggle.textContent = '▶';
|
||||
}
|
||||
};
|
||||
nodeEl.appendChild(toggle);
|
||||
} else {
|
||||
const spacer = document.createElement('span');
|
||||
spacer.className = 'w-3 inline-block flex-shrink-0';
|
||||
nodeEl.appendChild(spacer);
|
||||
}
|
||||
|
||||
// 유형 뱃지
|
||||
const badge = document.createElement('span');
|
||||
badge.className = getTypeBadgeClass(node.item_type);
|
||||
badge.textContent = node.item_type;
|
||||
badge.title = getTypeTitle(node.item_type);
|
||||
nodeEl.appendChild(badge);
|
||||
|
||||
// 코드
|
||||
const codeSpan = document.createElement('span');
|
||||
codeSpan.className = 'font-mono text-xs text-gray-500 flex-shrink-0';
|
||||
codeSpan.textContent = node.code;
|
||||
nodeEl.appendChild(codeSpan);
|
||||
|
||||
// 이름
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'text-gray-700 text-sm truncate';
|
||||
nameSpan.textContent = node.name;
|
||||
nodeEl.appendChild(nameSpan);
|
||||
|
||||
// 수량
|
||||
if (node.quantity) {
|
||||
const qtySpan = document.createElement('span');
|
||||
qtySpan.className = 'text-blue-600 text-xs font-medium ml-auto flex-shrink-0';
|
||||
qtySpan.textContent = `x${node.quantity}`;
|
||||
nodeEl.appendChild(qtySpan);
|
||||
}
|
||||
|
||||
li.appendChild(nodeEl);
|
||||
|
||||
// 자식 노드 재귀 렌더링
|
||||
if (node.children && node.children.length > 0) {
|
||||
childList = document.createElement('ul');
|
||||
childList.className = 'border-l-2 border-amber-200 ml-3';
|
||||
node.children.forEach(child => renderBendingNode(child, childList, depth + 1));
|
||||
li.appendChild(childList);
|
||||
}
|
||||
|
||||
container.appendChild(li);
|
||||
}
|
||||
|
||||
// ── FG 코드 파싱 → 입력폼 자동 세팅 ──
|
||||
|
||||
@@ -930,6 +930,7 @@
|
||||
Route::get('/{id}/history', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'history'])->name('history');
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/{id}/calculate-formula', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'calculateFormula'])->name('calculate-formula');
|
||||
Route::get('/bending-bom-tree', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'bendingBomTree'])->name('bending-bom-tree');
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user