2025-11-06 17:45:49 +09:00
< ? php $CURRENT_SECTION = 'permission' ;
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 " 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 = ' ' . 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' ; ?>