@@ -800,9 +800,334 @@ function MenuManagement() {
}
// ==========================================
// 권한 관리 (authMngList.jsp 대응)
// 권한 관리 — 좌(목록) / 중(있는·없는 직원) / 하(메뉴 트리) 통합 화면
// ==========================================
interface AuthGroup { OBJID : string ; AUTH_NAME : string ; AUTH_CODE : string ; USER_CNT : number ; STATUS : string }
interface AuthUserRow { USER_ID : string ; USER_NAME : string ; DEPT_NAME? : string ; OBJID? : string }
interface MenuRow { OBJID : string ; PARENT_OBJ_ID : string ; MENU_NAME_KOR : string ; MENU_URL : string ; SEQ : number ; MENU_TYPE : number ; STATUS : string }
function AuthManagement() {
const [ groups , setGroups ] = useState < AuthGroup [ ] > ( [ ] ) ;
const [ groupQuery , setGroupQuery ] = useState ( "" ) ;
const [ activeGroup , setActiveGroup ] = useState < AuthGroup | null > ( null ) ;
// 좌측 권한 목록 검색
const filteredGroups = useMemo ( ( ) = > groups . filter ( ( g ) = >
! groupQuery || g . AUTH_NAME . toLowerCase ( ) . includes ( groupQuery . toLowerCase ( ) ) || g . AUTH_CODE ? . toLowerCase ( ) . includes ( groupQuery . toLowerCase ( ) )
) , [ groups , groupQuery ] ) ;
// 권한 그룹 목록 조회
const fetchGroups = useCallback ( async ( ) = > {
const res = await fetch ( "/api/admin/auth" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : "{}" } ) ;
const j = await res . json ( ) ;
setGroups ( ( j . RESULTLIST || [ ] ) as AuthGroup [ ] ) ;
} , [ ] ) ;
useEffect ( ( ) = > { fetchGroups ( ) ; } , [ fetchGroups ] ) ;
// 권한 그룹 생성/이름 수정/삭제 (인라인)
const onCreate = async ( ) = > {
const r = await Swal . fire ( {
title : "권한그룹 생성" ,
html : ` <input id="sw_name" class="swal2-input" placeholder="권한명 (예: 영업팀 권한)">
<input id="sw_code" class="swal2-input" placeholder="권한CODE (예: SALES_TEAM)"> ` ,
showCancelButton : true , confirmButtonText : "생성" ,
preConfirm : ( ) = > ( {
auth_name : ( document . getElementById ( "sw_name" ) as HTMLInputElement ) . value ,
auth_code : ( document . getElementById ( "sw_code" ) as HTMLInputElement ) . value ,
} ) ,
} ) ;
if ( ! r . isConfirmed || ! r . value ? . auth_name ) return ;
const res = await fetch ( "/api/admin/auth/save" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( { . . . r . value , status : "active" , actionType : "regist" } ) } ) ;
const j = await res . json ( ) ;
if ( j . success ) { fetchGroups ( ) ; }
else Swal . fire ( "오류" , j . message ? ? "생성 실패" , "error" ) ;
} ;
const onRename = async ( g : AuthGroup ) = > {
const r = await Swal . fire ( {
title : "권한 그룹 수정" , icon : "info" ,
html : ` <input id="sw_name" class="swal2-input" value=" ${ g . AUTH_NAME } ">
<input id="sw_code" class="swal2-input" value=" ${ g . AUTH_CODE ? ? "" } "> ` ,
showCancelButton : true , showDenyButton : true , denyButtonText : "삭제" , confirmButtonText : "저장" ,
preConfirm : ( ) = > ( {
auth_name : ( document . getElementById ( "sw_name" ) as HTMLInputElement ) . value ,
auth_code : ( document . getElementById ( "sw_code" ) as HTMLInputElement ) . value ,
} ) ,
} ) ;
if ( r . isDenied ) {
const ok = await Swal . fire ( { icon : "warning" , title : "권한그룹 삭제" , text : g.AUTH_NAME , showCancelButton : true } ) ;
if ( ! ok . isConfirmed ) return ;
const res = await fetch ( "/api/admin/auth/delete" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( { objid : g.OBJID } ) } ) ;
if ( ( await res . json ( ) ) . success ) { if ( activeGroup ? . OBJID === g . OBJID ) setActiveGroup ( null ) ; fetchGroups ( ) ; }
return ;
}
if ( ! r . isConfirmed || ! r . value ? . auth_name ) return ;
const res = await fetch ( "/api/admin/auth/save" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( { objid : g.OBJID , . . . r . value , status : g.STATUS } ) } ) ;
if ( ( await res . json ( ) ) . success ) fetchGroups ( ) ;
} ;
return (
< div className = "space-y-3" >
< div >
< h2 className = "text-lg font-bold text-slate-900" > 권 한 관 리 < / h2 >
< p className = "text-xs text-slate-500 mt-0.5" > 권 한 그 룹 선 택 시 권 한 있 는 / 없 는 직 원 과 메 뉴 권 한 이 로 드 되 고 , 체 크 즉 시 반 영 됩 니 다 . < / p >
< / div >
< div className = "grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-3" >
{ /* 좌측: 권한 그룹 목록 */ }
< div className = "bg-white border border-slate-200 rounded-xl flex flex-col" >
< div className = "px-3 py-2 border-b border-slate-100 flex items-center justify-between" >
< div className = "text-sm font-bold text-slate-700 inline-flex items-center gap-1.5" > < Shield size = { 14 } className = "text-emerald-700" / > 권 한 목 록 < / div >
< button onClick = { onCreate } className = "inline-flex items-center gap-1 h-7 px-2.5 rounded bg-emerald-600 text-white text-[11px] font-bold hover:bg-emerald-700" > < span > + 생 성 < / span > < / button >
< / div >
< div className = "px-3 py-2 border-b border-slate-100" >
< input value = { groupQuery } onChange = { ( e ) = > setGroupQuery ( e . target . value ) } placeholder = "검색..." className = "w-full h-8 px-2 text-xs border border-slate-200 rounded" / >
< / div >
< div className = "flex-1 overflow-auto max-h-[calc(100vh-360px)] divide-y divide-slate-100" >
{ filteredGroups . length === 0 ? (
< div className = "p-6 text-center text-xs text-slate-400" > 권 한 그 룹 이 없 습 니 다 . < / div >
) : filteredGroups . map ( ( g ) = > (
< button
key = { g . OBJID }
onClick = { ( ) = > setActiveGroup ( g ) }
onDoubleClick = { ( ) = > onRename ( g ) }
className = { cn ( "w-full text-left px-3 py-2 hover:bg-slate-50 transition-colors" , activeGroup ? . OBJID === g . OBJID && "bg-emerald-50/70 border-l-4 border-l-emerald-600" ) }
title = "더블클릭: 수정/삭제"
>
< div className = "text-sm font-bold text-slate-800" > { g . AUTH_NAME } < / div >
< div className = "text-[10px] text-slate-400 font-mono" > { g . AUTH_CODE || "-" } < / div >
< / button >
) ) }
< / div >
< / div >
{ /* 우측: 활성 그룹의 직원 + 메뉴 매핑 */ }
< div className = "space-y-3" >
{ activeGroup ? (
< >
< AuthGroupMembers group = { activeGroup } onChanged = { fetchGroups } / >
< AuthGroupMenus group = { activeGroup } / >
< / >
) : (
< div className = "bg-white border border-slate-200 rounded-xl p-12 text-center text-slate-400 text-sm" >
왼 쪽 에 서 권 한 그 룹 을 선 택 하 거 나 [ + 생 성 ] 으 로 새 로 만 드 세 요 .
< / div >
) }
< / div >
< / div >
< / div >
) ;
}
// 직원 — 권한있는 / 권한없는 양쪽 패널
function AuthGroupMembers ( { group , onChanged } : { group : AuthGroup ; onChanged : ( ) = > void } ) {
const [ members , setMembers ] = useState < AuthUserRow [ ] > ( [ ] ) ;
const [ available , setAvailable ] = useState < AuthUserRow [ ] > ( [ ] ) ;
const [ memberQ , setMemberQ ] = useState ( "" ) ;
const [ availQ , setAvailQ ] = useState ( "" ) ;
const [ chkMember , setChkMember ] = useState < Set < string > > ( new Set ( ) ) ;
const [ chkAvail , setChkAvail ] = useState < Set < string > > ( new Set ( ) ) ;
const reload = useCallback ( async ( ) = > {
setChkMember ( new Set ( ) ) ; setChkAvail ( new Set ( ) ) ;
const [ m , u ] = await Promise . all ( [
fetch ( "/api/admin/auth/members" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( { masterObjid : group.OBJID } ) } ) . then ( ( r ) = > r . json ( ) ) ,
fetch ( "/api/admin/auth/users" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( { masterObjid : group.OBJID } ) } ) . then ( ( r ) = > r . json ( ) ) ,
] ) ;
setMembers ( ( m . RESULTLIST || [ ] ) as AuthUserRow [ ] ) ;
setAvailable ( ( u . RESULTLIST || [ ] ) as AuthUserRow [ ] ) ;
} , [ group . OBJID ] ) ;
useEffect ( ( ) = > { reload ( ) ; } , [ reload ] ) ;
const filteredMembers = useMemo ( ( ) = > members . filter ( ( m ) = > ! memberQ || ( m . USER_NAME ? ? "" ) . includes ( memberQ ) || ( m . USER_ID ? ? "" ) . includes ( memberQ ) || ( m . DEPT_NAME ? ? "" ) . includes ( memberQ ) ) , [ members , memberQ ] ) ;
const filteredAvail = useMemo ( ( ) = > available . filter ( ( m ) = > ! availQ || ( m . USER_NAME ? ? "" ) . includes ( availQ ) || ( m . USER_ID ? ? "" ) . includes ( availQ ) || ( m . DEPT_NAME ? ? "" ) . includes ( availQ ) ) , [ available , availQ ] ) ;
const toggleAllMember = ( on : boolean ) = > setChkMember ( on ? new Set ( filteredMembers . map ( ( m ) = > String ( m . OBJID ) ) ) : new Set ( ) ) ;
const toggleAllAvail = ( on : boolean ) = > setChkAvail ( on ? new Set ( filteredAvail . map ( ( m ) = > String ( m . USER_ID ) ) ) : new Set ( ) ) ;
const addSelected = async ( ) = > {
const userIds = Array . from ( chkAvail ) ;
if ( userIds . length === 0 ) { Swal . fire ( "알림" , "추가할 직원을 선택하세요." , "warning" ) ; return ; }
const res = await fetch ( "/api/admin/auth/members/save" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( { masterObjid : group.OBJID , userIds } ) } ) ;
const j = await res . json ( ) . catch ( ( ) = > ( { success : false , message : "응답 파싱 실패" } ) ) ;
if ( ! j . success ) { Swal . fire ( { icon : "error" , title : "추가 실패" , text : j.message } ) ; return ; }
await reload ( ) ; onChanged ( ) ;
} ;
const removeSelected = async ( ) = > {
const memberObjids = Array . from ( chkMember ) ;
if ( memberObjids . length === 0 ) { Swal . fire ( "알림" , "제거할 직원을 선택하세요." , "warning" ) ; return ; }
const res = await fetch ( "/api/admin/auth/members/delete" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( { masterObjid : group.OBJID , memberObjids } ) } ) ;
const j = await res . json ( ) . catch ( ( ) = > ( { success : false , message : "응답 파싱 실패" } ) ) ;
if ( ! j . success ) { Swal . fire ( { icon : "error" , title : "제거 실패" , text : j.message } ) ; return ; }
await reload ( ) ; onChanged ( ) ;
} ;
return (
< div className = "grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-3" >
{ /* 권한있는 직원 */ }
< div className = "bg-white border border-slate-200 rounded-xl flex flex-col" >
< div className = "px-3 py-2 border-b border-slate-100 text-sm font-bold text-slate-700 inline-flex items-center gap-1.5" >
< Users size = { 14 } className = "text-emerald-700" / > 권 한 있 는 직 원 ( { members . length } )
< / div >
< div className = "p-2 flex items-center gap-2 border-b border-slate-100" >
< label className = "text-xs inline-flex items-center gap-1.5" >
< input type = "checkbox" onChange = { ( e ) = > toggleAllMember ( e . target . checked ) } className = "w-4 h-4 accent-emerald-600" / > 전 체 선 택
< / label >
< input value = { memberQ } onChange = { ( e ) = > setMemberQ ( e . target . value ) } placeholder = "검색" className = "ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" / >
< / div >
< div className = "flex-1 overflow-auto max-h-[40vh]" >
< table className = "w-full text-xs" >
< thead className = "bg-slate-50 sticky top-0" >
< tr > < th className = "w-8 p-1.5" > < / th > < th className = "text-left p-1.5" > 부 서 < / th > < th className = "text-left p-1.5" > 이 름 < / th > < th className = "text-left p-1.5" > ID < / th > < / tr >
< / thead >
< tbody >
{ filteredMembers . map ( ( m ) = > {
const id = String ( m . OBJID ) ;
return (
< tr key = { id } className = "border-t hover:bg-slate-50" >
< td className = "text-center p-1.5" > < input type = "checkbox" checked = { chkMember . has ( id ) } onChange = { ( e ) = > { const s = new Set ( chkMember ) ; if ( e . target . checked ) s . add ( id ) ; else s . delete ( id ) ; setChkMember ( s ) ; } } className = "w-4 h-4 accent-emerald-600" / > < / td >
< td className = "p-1.5" > { m . DEPT_NAME || "-" } < / td >
< td className = "p-1.5 font-semibold" > { m . USER_NAME } < / td >
< td className = "p-1.5 text-slate-400 font-mono" > { m . USER_ID } < / td >
< / tr >
) ;
} ) }
{ filteredMembers . length === 0 && < tr > < td colSpan = { 4 } className = "p-6 text-center text-slate-400" > 권 한 그 룹 을 선 택 하 세 요 < / td > < / tr > }
< / tbody >
< / table >
< / div >
< / div >
{ /* 추가/제거 버튼 */ }
< div className = "flex flex-row lg:flex-col items-center justify-center gap-2 px-2" >
< button onClick = { addSelected } className = "h-9 w-24 rounded bg-emerald-600 text-white text-xs font-bold hover:bg-emerald-700 disabled:opacity-50" > ‹ 추 가 < / button >
< button onClick = { removeSelected } className = "h-9 w-24 rounded border border-slate-300 text-slate-700 text-xs font-bold hover:bg-slate-50" > 제 거 › < / button >
< / div >
{ /* 권한없는 직원 */ }
< div className = "bg-white border border-slate-200 rounded-xl flex flex-col" >
< div className = "px-3 py-2 border-b border-slate-100 text-sm font-bold text-slate-700 inline-flex items-center gap-1.5" >
< Users size = { 14 } className = "text-slate-400" / > 권 한 없 는 직 원 ( { available . length } )
< / div >
< div className = "p-2 flex items-center gap-2 border-b border-slate-100" >
< label className = "text-xs inline-flex items-center gap-1.5" >
< input type = "checkbox" onChange = { ( e ) = > toggleAllAvail ( e . target . checked ) } className = "w-4 h-4 accent-emerald-600" / > 전 체 선 택
< / label >
< input value = { availQ } onChange = { ( e ) = > setAvailQ ( e . target . value ) } placeholder = "검색" className = "ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" / >
< / div >
< div className = "flex-1 overflow-auto max-h-[40vh]" >
< table className = "w-full text-xs" >
< thead className = "bg-slate-50 sticky top-0" >
< tr > < th className = "w-8 p-1.5" > < / th > < th className = "text-left p-1.5" > 부 서 < / th > < th className = "text-left p-1.5" > 이 름 < / th > < th className = "text-left p-1.5" > ID < / th > < / tr >
< / thead >
< tbody >
{ filteredAvail . map ( ( m ) = > {
const id = String ( m . USER_ID ) ;
return (
< tr key = { id } className = "border-t hover:bg-slate-50" >
< td className = "text-center p-1.5" > < input type = "checkbox" checked = { chkAvail . has ( id ) } onChange = { ( e ) = > { const s = new Set ( chkAvail ) ; if ( e . target . checked ) s . add ( id ) ; else s . delete ( id ) ; setChkAvail ( s ) ; } } className = "w-4 h-4 accent-emerald-600" / > < / td >
< td className = "p-1.5" > { m . DEPT_NAME || "-" } < / td >
< td className = "p-1.5 font-semibold" > { m . USER_NAME } < / td >
< td className = "p-1.5 text-slate-400 font-mono" > { m . USER_ID } < / td >
< / tr >
) ;
} ) }
{ filteredAvail . length === 0 && < tr > < td colSpan = { 4 } className = "p-6 text-center text-slate-400" > 권 한 그 룹 을 선 택 하 세 요 < / td > < / tr > }
< / tbody >
< / table >
< / div >
< / div >
< / div >
) ;
}
// 메뉴 트리 — 체크 즉시 토글
function AuthGroupMenus ( { group } : { group : AuthGroup } ) {
const [ menus , setMenus ] = useState < MenuRow [ ] > ( [ ] ) ;
const [ assigned , setAssigned ] = useState < Set < string > > ( new Set ( ) ) ;
const [ pending , setPending ] = useState < Set < string > > ( new Set ( ) ) ;
const reload = useCallback ( async ( ) = > {
const res = await fetch ( "/api/admin/auth/menus" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( { masterObjid : group.OBJID } ) } ) ;
const j = await res . json ( ) ;
setMenus ( ( j . MENULIST || [ ] ) as MenuRow [ ] ) ;
setAssigned ( new Set ( ( ( j . ASSIGNED || [ ] ) as string [ ] ) . map ( String ) ) ) ;
} , [ group . OBJID ] ) ;
useEffect ( ( ) = > { reload ( ) ; } , [ reload ] ) ;
// 트리 빌드
const tree = useMemo ( ( ) = > {
const byParent = new Map < string , MenuRow [ ] > ( ) ;
for ( const m of menus ) {
const p = String ( m . PARENT_OBJ_ID ? ? "" ) ;
if ( ! byParent . has ( p ) ) byParent . set ( p , [ ] ) ;
byParent . get ( p ) ! . push ( m ) ;
}
const roots = menus . filter ( ( m ) = > ! m . PARENT_OBJ_ID || m . PARENT_OBJ_ID === "0" || m . PARENT_OBJ_ID === "" || ! menus . some ( ( x ) = > String ( x . OBJID ) === String ( m . PARENT_OBJ_ID ) ) ) ;
return { byParent , roots } ;
} , [ menus ] ) ;
const toggle = async ( menu : MenuRow ) = > {
const id = String ( menu . OBJID ) ;
const on = ! assigned . has ( id ) ;
setPending ( ( p ) = > new Set ( p ) . add ( id ) ) ;
setAssigned ( ( prev ) = > { const n = new Set ( prev ) ; if ( on ) n . add ( id ) ; else n . delete ( id ) ; return n ; } ) ;
try {
const res = await fetch ( "/api/admin/auth/menus/toggle" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON.stringify ( { masterObjid : group.OBJID , menuObjid : id , on } ) } ) ;
const j = await res . json ( ) ;
if ( ! j . success ) {
Swal . fire ( { icon : "error" , title : "메뉴 토글 실패" , text : j.message ? ? "" } ) ;
// 롤백
setAssigned ( ( prev ) = > { const n = new Set ( prev ) ; if ( on ) n . delete ( id ) ; else n . add ( id ) ; return n ; } ) ;
}
} finally {
setPending ( ( p ) = > { const n = new Set ( p ) ; n . delete ( id ) ; return n ; } ) ;
}
} ;
const renderNode = ( m : MenuRow , depth : number ) : React . ReactNode = > {
const id = String ( m . OBJID ) ;
const children = tree . byParent . get ( id ) || [ ] ;
return (
< div key = { id } >
< label className = { cn ( "flex items-center gap-2 py-1 px-2 hover:bg-slate-50 cursor-pointer text-sm" , depth > 0 && "pl-[" + ( depth * 20 + 8 ) + "px]" ) } style = { { paddingLeft : depth * 20 + 8 } } >
< input
type = "checkbox"
checked = { assigned . has ( id ) }
disabled = { pending . has ( id ) }
onChange = { ( ) = > toggle ( m ) }
className = "w-4 h-4 accent-emerald-600 cursor-pointer"
/ >
< span className = { cn ( "font-medium" , depth === 0 ? "text-slate-900 font-bold" : "text-slate-700" ) } > { m . MENU_NAME_KOR } < / span >
{ m . MENU_URL && < span className = "text-[10px] text-slate-400 font-mono" > { m . MENU_URL } < / span > }
{ m . STATUS !== "active" && < span className = "text-[10px] text-rose-500" > 비 활 성 < / span > }
< / label >
{ children . map ( ( c ) = > renderNode ( c , depth + 1 ) ) }
< / div >
) ;
} ;
return (
< div className = "bg-white border border-slate-200 rounded-xl" >
< div className = "px-3 py-2 border-b border-slate-100" >
< div className = "text-sm font-bold text-slate-700 inline-flex items-center gap-1.5" > < Menu size = { 14 } className = "text-emerald-700" / > 메 뉴 전 체 트 리 구 조 < / div >
< p className = "text-[11px] text-slate-400 mt-0.5" > 체 크 된 것 들 만 시 스 템 에 서 해 당 메 뉴 가 노 출 됩 니 다 · 체 크 즉 시 서 버 반 영 < / p >
< / div >
< div className = "max-h-[40vh] overflow-auto py-1" >
{ tree . roots . length === 0 ? (
< div className = "p-6 text-center text-slate-400 text-sm" > 메 뉴 가 없 습 니 다 . < / div >
) : tree . roots . map ( ( m ) = > renderNode ( m , 0 ) ) }
< / div >
< / div >
) ;
}
// 권한 관리 — 레거시 모달형 (단계적 deprecation)
function _AuthManagementLegacy() {
const [ data , setData ] = useState < Record < string , unknown > [ ] > ( [ ] ) ;
const [ selectedRows , setSelectedRows ] = useState < Record < string , unknown > [ ] > ( [ ] ) ;
// 등록/수정 폼