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

303 lines
15 KiB
PHP

<?php $CURRENT_SECTION = 'permission';
include '../inc/header.php'; ?>
<div class="container" style="max-width:1280px; margin-top:24px;">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabUse" type="button">메뉴 사용 설정</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabWorkflow" type="button">작업공정 연동(읽기전용)</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabBoards" type="button">게시판 연동(읽기전용)</button></li>
</ul>
<div class="tab-content border border-top-0 p-3">
<!-- 메뉴 사용 설정 (작업공정/게시판 연동도 함께 표시) -->
<div class="tab-pane fade show active" id="tabUse">
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<div class="input-group" style="max-width:420px;">
<input type="text" class="form-control" id="searchInput" placeholder="메뉴명/코드/출처 검색 (예: workflow, board)">
<button class="btn btn-outline-secondary" id="btnSearch">검색</button>
<button class="btn btn-outline-secondary" id="btnClear">초기화</button>
</div>
<div class="ms-auto d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" id="btnAllOn">전체 허용</button>
<button class="btn btn-sm btn-outline-danger" id="btnAllOff">전체 금지</button>
<button class="btn btn-sm btn-primary" id="btnSave">저장</button>
</div>
</div>
<div class="table-responsive" style="max-height:64vh; overflow:auto;">
<table class="table table-hover align-middle small" id="menuTable">
<thead class="table-light">
<tr>
<th style="width:56px;">사용</th>
<th>메뉴명</th>
<th style="width:200px;">코드</th>
<th style="width:260px;">경로</th>
<!-- ▼ 신규: 출처 컬럼 -->
<th style="width:120px;">출처</th>
<th style="width:120px;">구독기본</th>
<th style="width:120px;">강제고정</th>
</tr>
</thead>
<tbody><!-- JS 렌더 --></tbody>
</table>
</div>
<div class="form-text">
* <b>출처</b>: system(일반), <span class="text-lowercase">workflow</span>(작업공정 연동), <span class="text-lowercase">board</span>(게시판 연동)<br>
* <b>읽기전용</b> 항목은 직접 토글할 수 없으며, 상위 메뉴 ON/OFF에 따라 상태가 함께 변합니다.<br>
* <b>강제고정</b>: 테넌트에서 끌 수 없음(최고관리자 정책).
</div>
</div>
<!-- 작업공정 연동(읽기전용) -->
<div class="tab-pane fade" id="tabWorkflow">
<div class="alert alert-info py-2 small">작업공정에서 생성/연동된 메뉴 목록입니다. 사용 여부는 상단 “메뉴 사용 설정”에서 구조 전체와 함께 확인하세요.</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light"><tr><th>프로세스</th><th>연동 메뉴</th><th>메뉴 코드</th><th>경로</th></tr></thead>
<tbody id="workflowRows"><!-- JS --></tbody>
</table>
</div>
</div>
<!-- 게시판 연동(읽기전용) -->
<div class="tab-pane fade" id="tabBoards">
<div class="alert alert-info py-2 small">게시판 생성 시 연결된 메뉴 목록입니다.</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light"><tr><th>게시판 코드</th><th>게시판 명</th><th>연동 메뉴</th><th>메뉴 코드</th></tr></thead>
<tbody id="boardRows"><!-- JS --></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// ===== 샘플 데이터 (실전에서는 AJAX 로드) =====
// source: 'system' | 'workflow' | 'board'
// readonly: true 이면 직접 토글 불가(체크박스 disabled)
const MENU_DATA = [
{id:1, parent_id:null, title:'대시보드', code:'dashboard', path:'/tenant/member/dashboard.php', forced:false, plan_default:true, source:'system', readonly:false},
{id:2, parent_id:null, title:'수주', code:'order', path:null, forced:false, plan_default:true, source:'system', readonly:false},
{id:21, parent_id:2, title:'수주 관리', code:'order.manage', path:'/tenant/order/manage.php', forced:false, plan_default:true, source:'system', readonly:false},
{id:22, parent_id:2, title:'수주 등록/수정', code:'order.edit', path:'/tenant/order/edit.php', forced:false, plan_default:false, source:'system', readonly:false},
{id:3, parent_id:null, title:'생산', code:'production', path:null, forced:true, plan_default:true, source:'system', readonly:false},
// ▼ 작업공정 연동 예시 (readonly)
{id:31, parent_id:3, title:'작업 지시', code:'production.wi', path:'/tenant/production/work_instruction.php', forced:true, plan_default:true, source:'workflow', readonly:true},
{id:32, parent_id:3, title:'스크린 작업', code:'production.screen', path:'/tenant/production/screen_work.php', forced:false, plan_default:true, source:'workflow', readonly:true},
{id:4, parent_id:null, title:'자재', code:'material', path:null, forced:false, plan_default:true, source:'system', readonly:false},
// ▼ 작업공정/품질 연동 예시 (readonly)
{id:41, parent_id:4, title:'수입 검사 대장', code:'material.insp', path:'/tenant/material/inspection_list.php', forced:false, plan_default:false, source:'workflow', readonly:true},
// ▼ 게시판 연동 예시 (readonly)
{id:5, parent_id:null, title:'게시판', code:'board', path:null, forced:false, plan_default:true, source:'system', readonly:false},
{id:51, parent_id:5, title:'공지사항', code:'board.notice', path:'/tenant/board/notice_list.php', forced:false, plan_default:true, source:'board', readonly:true},
{id:52, parent_id:5, title:'Q&A', code:'board.qna', path:'/tenant/board/qna_list.php', forced:false, plan_default:false, source:'board', readonly:true},
];
// 테넌트 현재 설정
const TENANT_USE = {
'dashboard': true,
'order': true,
'order.manage': true,
'order.edit': false,
'production': true,
'production.wi': true,
'production.screen': true,
'material': true,
'material.insp': false,
'board': true,
'board.notice': true,
'board.qna': false,
};
// ===== 유틸/트리 =====
const byId = MENU_DATA.reduce((m,x)=> (m[x.id]=x,m),{});
const childrenMap = MENU_DATA.reduce((m,x)=>{ (m[x.parent_id??0]??=([])).push(x); return m; },{});
function buildRows(parentId=null, depth=0, rows=[]){
const list = childrenMap[parentId??0] || [];
list.sort((a,b)=> (a.title > b.title ? 1 : -1));
for (const node of list){
const enabled = !!TENANT_USE[node.code];
rows.push(renderRow(node, depth, enabled));
buildRows(node.id, depth+1, rows);
}
return rows.join('');
}
function badge(text, cls){ return `<span class="badge ${cls} ms-1">${text}</span>`; }
function renderRow(node, depth, enabled){
const indent = '&nbsp;'.repeat(depth*4) + (depth? '└ ' : '');
const isReadOnly = !!node.readonly;
const isForced = !!node.forced;
const disabledAttr = (isForced || isReadOnly) ? 'disabled' : '';
const checkedAttr = (isForced ? 'checked' : (enabled ? 'checked' : ''));
const src = node.source || 'system';
const sourceBadge =
src==='workflow' ? badge('workflow','bg-warning text-dark') :
src==='board' ? badge('board','bg-success') :
badge('system','bg-secondary');
const forceBadge = isForced ? badge('강제','bg-secondary') : '';
const roBadge = isReadOnly ? badge('읽기전용','bg-dark') : '';
const planBadge = node.plan_default ? badge('플랜기본','bg-info text-dark') : '';
const path = node.path ? node.path : '-';
return `
<tr data-id="${node.id}" data-code="${node.code}" data-parent="${node.parent_id??''}">
<td class="text-center">
<input type="checkbox" class="chk-use" ${checkedAttr} ${disabledAttr} title="${isReadOnly?'연동 항목은 직접 변경할 수 없습니다.':''}">
</td>
<td><span class="text-monospace">${indent}</span>${node.title}${forceBadge}${roBadge}</td>
<td class="text-muted">${node.code}</td>
<td class="text-muted">${path}</td>
<td>${sourceBadge}</td>
<td>${node.plan_default ? '기본 사용' : '-'}</td>
<td>${isForced ? '강제 고정' : '-'}</td>
</tr>`;
}
function setChildren(code, on){
const node = MENU_DATA.find(x=>x.code===code);
if (!node) return;
(childrenMap[node.id]||[]).forEach(ch=>{
// 하위는 readonly여도 부모 변경에 따라 상태는 동기화 (직접토글만 막음)
TENANT_USE[ch.code]=on;
setChildren(ch.code, on);
});
}
function updateParents(code){
const node = MENU_DATA.find(x=>x.code===code);
if (!node || !node.parent_id) return;
const parent = byId[node.parent_id];
const kids = (childrenMap[parent.id]||[]);
const states = kids.map(k=> !!TENANT_USE[k.code]);
const allOn = states.every(Boolean);
const allOff= states.every(s=>!s);
if (!parent.forced){
TENANT_USE[parent.code] = allOn ? true : (allOff ? false : true);
}
updateParents(parent.code);
}
function applyIndeterminate(){
$('#menuTable tbody tr').each(function(){
const id = $(this).data('id');
const code = $(this).data('code');
const $chk = $(this).find('.chk-use')[0];
if (!$chk) return;
$chk.indeterminate = false;
const kids = childrenMap[id]||[];
if (kids.length){
const onCnt = kids.filter(k=> TENANT_USE[k.code]).length;
if (onCnt>0 && onCnt<kids.length){ $chk.indeterminate = true; }
}
// forced/readOnly 도구팁
const node = byId[id];
if (node.forced){ $chk.title = '강제 고정 메뉴입니다.'; }
});
}
function render(){
$('#menuTable tbody').html( buildRows(null,0,[]) );
// 체크박스 상태 반영
$('#menuTable .chk-use').each(function(){
const code = $(this).closest('tr').data('code');
this.checked = !!TENANT_USE[code] || byId[$(this).closest('tr').data('id')].forced;
});
applyIndeterminate();
// 하단 탭(읽기전용 목록)도 같이 채움
renderSubLists();
}
function renderSubLists(){
// 작업공정
const wf = MENU_DATA.filter(x=>x.source==='workflow');
$('#workflowRows').html(wf.map(x=>`
<tr>
<td>${x.title.includes('작업')? x.title.replace(/ 작업.*/,' 작업') : '프로세스'}</td>
<td>${x.title}</td>
<td class="text-muted">${x.code}</td>
<td class="text-muted">${x.path||'-'}</td>
</tr>`).join(''));
// 게시판
const bd = MENU_DATA.filter(x=>x.source==='board');
$('#boardRows').html(bd.map(x=>`
<tr>
<td class="text-muted">${x.code.split('.').pop().toUpperCase()}</td>
<td>${x.title}</td>
<td>${byId[x.parent_id]?.title||'-'}</td>
<td class="text-muted">${x.code}</td>
</tr>`).join(''));
}
$(function(){
render();
// 직접 토글
$(document).on('change','.chk-use', function(){
const $tr = $(this).closest('tr');
const node = byId[$tr.data('id')];
const code = node.code;
// 강제/읽기전용은 직접 토글 금지
if (node.forced || node.readonly){
this.checked = !!TENANT_USE[code]; // 원상복구
return;
}
TENANT_USE[code] = this.checked;
setChildren(code, this.checked);
updateParents(code);
render();
});
// 전체 허용/금지 (강제/readonly 제외)
$('#btnAllOn').on('click', ()=>{
MENU_DATA.forEach(n=>{ if(!n.forced && !n.readonly) TENANT_USE[n.code]=true; });
render();
});
$('#btnAllOff').on('click', ()=>{
MENU_DATA.forEach(n=>{ if(!n.forced && !n.readonly) TENANT_USE[n.code]=false; });
render();
});
// 저장
$('#btnSave').on('click', ()=>{
const payload = Object.fromEntries(MENU_DATA.map(n=> [n.code, !!TENANT_USE[n.code]]));
console.log('SAVE', payload);
alert('저장(샘플) — /api/permission/tenant_menu_save.php');
});
// 검색: 메뉴명/코드/출처
const doSearch = ()=>{
const q = $('#searchInput').val().trim().toLowerCase();
if (!q){ $('#menuTable tbody tr').show(); return; }
$('#menuTable tbody tr').each(function(){
const name = $(this).find('td:nth-child(2)').text().toLowerCase();
const code = $(this).find('td:nth-child(3)').text().toLowerCase();
const src = $(this).find('td:nth-child(5)').text().toLowerCase();
$(this).toggle( name.includes(q) || code.includes(q) || src.includes(q) );
});
};
$('#btnSearch').on('click', doSearch);
$('#btnClear').on('click', ()=>{ $('#searchInput').val(''); $('#menuTable tbody tr').show(); });
});
</script>
<?php include '../inc/footer.php'; ?>