diff --git a/db/migrations/020_authority_sub_menu.sql b/db/migrations/020_authority_sub_menu.sql new file mode 100644 index 0000000..a27913c --- /dev/null +++ b/db/migrations/020_authority_sub_menu.sql @@ -0,0 +1,14 @@ +-- 권한그룹 ↔ 메뉴 매핑 테이블 +-- 권한 관리 화면에서 그룹별로 노출 메뉴를 체크박스로 매핑하기 위함 + +CREATE TABLE IF NOT EXISTS authority_sub_menu ( + objid numeric PRIMARY KEY, + master_objid numeric NOT NULL, + menu_objid numeric NOT NULL, + writer varchar(100), + regdate timestamp without time zone DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_authority_sub_menu_master ON authority_sub_menu(master_objid); +CREATE INDEX IF NOT EXISTS idx_authority_sub_menu_menu ON authority_sub_menu(menu_objid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_authority_sub_menu_pair ON authority_sub_menu(master_objid, menu_objid); diff --git a/src/app/(main)/m/admin/orders/page.tsx b/src/app/(main)/m/admin/orders/page.tsx index 8a5536f..1f0e0e9 100644 --- a/src/app/(main)/m/admin/orders/page.tsx +++ b/src/app/(main)/m/admin/orders/page.tsx @@ -577,7 +577,7 @@ function StatementPreview({ 품명 구분 현재고 - 수량 + 수량 단가 공급가 세액 diff --git a/src/app/admin-panel/page.tsx b/src/app/admin-panel/page.tsx index 4506d48..d50a2bc 100644 --- a/src/app/admin-panel/page.tsx +++ b/src/app/admin-panel/page.tsx @@ -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([]); + const [groupQuery, setGroupQuery] = useState(""); + const [activeGroup, setActiveGroup] = useState(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: ` + `, + 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: ` + `, + 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 ( +
+
+

권한 관리

+

권한 그룹 선택 시 권한있는/없는 직원과 메뉴 권한이 로드되고, 체크 즉시 반영됩니다.

+
+ +
+ {/* 좌측: 권한 그룹 목록 */} +
+
+
권한 목록
+ +
+
+ setGroupQuery(e.target.value)} placeholder="검색..." className="w-full h-8 px-2 text-xs border border-slate-200 rounded" /> +
+
+ {filteredGroups.length === 0 ? ( +
권한 그룹이 없습니다.
+ ) : filteredGroups.map((g) => ( + + ))} +
+
+ + {/* 우측: 활성 그룹의 직원 + 메뉴 매핑 */} +
+ {activeGroup ? ( + <> + + + + ) : ( +
+ 왼쪽에서 권한 그룹을 선택하거나 [+ 생성] 으로 새로 만드세요. +
+ )} +
+
+
+ ); +} + +// 직원 — 권한있는 / 권한없는 양쪽 패널 +function AuthGroupMembers({ group, onChanged }: { group: AuthGroup; onChanged: () => void }) { + const [members, setMembers] = useState([]); + const [available, setAvailable] = useState([]); + const [memberQ, setMemberQ] = useState(""); + const [availQ, setAvailQ] = useState(""); + const [chkMember, setChkMember] = useState>(new Set()); + const [chkAvail, setChkAvail] = useState>(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 ( +
+ {/* 권한있는 직원 */} +
+
+ 권한있는 직원 ({members.length}) +
+
+ + setMemberQ(e.target.value)} placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" /> +
+
+ + + + + + {filteredMembers.map((m) => { + const id = String(m.OBJID); + return ( + + + + + + + ); + })} + {filteredMembers.length === 0 && } + +
부서이름ID
{ 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" />{m.DEPT_NAME || "-"}{m.USER_NAME}{m.USER_ID}
권한 그룹을 선택하세요
+
+
+ + {/* 추가/제거 버튼 */} +
+ + +
+ + {/* 권한없는 직원 */} +
+
+ 권한없는 직원 ({available.length}) +
+
+ + setAvailQ(e.target.value)} placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" /> +
+
+ + + + + + {filteredAvail.map((m) => { + const id = String(m.USER_ID); + return ( + + + + + + + ); + })} + {filteredAvail.length === 0 && } + +
부서이름ID
{ 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" />{m.DEPT_NAME || "-"}{m.USER_NAME}{m.USER_ID}
권한 그룹을 선택하세요
+
+
+
+ ); +} + +// 메뉴 트리 — 체크 즉시 토글 +function AuthGroupMenus({ group }: { group: AuthGroup }) { + const [menus, setMenus] = useState([]); + const [assigned, setAssigned] = useState>(new Set()); + const [pending, setPending] = useState>(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(); + 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 ( +
+ + {children.map((c) => renderNode(c, depth + 1))} +
+ ); + }; + + return ( +
+
+
메뉴 전체 트리구조
+

체크된 것들만 시스템에서 해당 메뉴가 노출됩니다 · 체크 즉시 서버 반영

+
+
+ {tree.roots.length === 0 ? ( +
메뉴가 없습니다.
+ ) : tree.roots.map((m) => renderNode(m, 0))} +
+
+ ); +} + +// 권한 관리 — 레거시 모달형 (단계적 deprecation) +function _AuthManagementLegacy() { const [data, setData] = useState[]>([]); const [selectedRows, setSelectedRows] = useState[]>([]); // 등록/수정 폼 diff --git a/src/app/api/admin/auth/members/save/route.ts b/src/app/api/admin/auth/members/save/route.ts index c438838..a905693 100644 --- a/src/app/api/admin/auth/members/save/route.ts +++ b/src/app/api/admin/auth/members/save/route.ts @@ -27,9 +27,9 @@ export async function POST(request: NextRequest) { const objid = createObjectId(); const rowCount = await execute( `INSERT INTO AUTHORITY_SUB_USER (OBJID, MASTER_OBJID, USER_ID, WRITER, REGDATE) - SELECT $1::numeric, $2::numeric, $3, $4, now() + SELECT $1::numeric, $2::numeric, $3::text, $4::text, now() WHERE NOT EXISTS ( - SELECT 1 FROM AUTHORITY_SUB_USER WHERE USER_ID = $3 AND MASTER_OBJID = $2::numeric + SELECT 1 FROM AUTHORITY_SUB_USER WHERE USER_ID = $3::text AND MASTER_OBJID = $2::numeric )`, [objid, masterObjid, userId, user.userId] ); diff --git a/src/app/api/admin/auth/menus/route.ts b/src/app/api/admin/auth/menus/route.ts new file mode 100644 index 0000000..6b7619e --- /dev/null +++ b/src/app/api/admin/auth/menus/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRows } from "@/lib/db"; +import { getSession } from "@/lib/session"; + +// 특정 권한그룹이 가진 메뉴 OBJID 목록 + 전체 메뉴 트리 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const { masterObjid } = await request.json().catch(() => ({})); + + // 전체 메뉴 트리 + const menus = await queryRows(` + SELECT objid::text AS "OBJID", + parent_obj_id::text AS "PARENT_OBJ_ID", + menu_name_kor AS "MENU_NAME_KOR", + menu_url AS "MENU_URL", + seq::int AS "SEQ", + menu_type::int AS "MENU_TYPE", + COALESCE(status,'active') AS "STATUS" + FROM menu_info + ORDER BY menu_type, seq + `); + + // 그룹이 가진 메뉴 OBJID 목록 + let assigned: string[] = []; + if (masterObjid) { + const rows = await queryRows<{ MENU_OBJID: string }>( + `SELECT menu_objid::text AS "MENU_OBJID" FROM authority_sub_menu WHERE master_objid = $1::numeric`, + [masterObjid] + ); + assigned = rows.map((r) => r.MENU_OBJID); + } + + return NextResponse.json({ success: true, MENULIST: menus, ASSIGNED: assigned }); +} diff --git a/src/app/api/admin/auth/menus/toggle/route.ts b/src/app/api/admin/auth/menus/toggle/route.ts new file mode 100644 index 0000000..1464311 --- /dev/null +++ b/src/app/api/admin/auth/menus/toggle/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { execute } from "@/lib/db"; +import { getSession } from "@/lib/session"; +import { createObjectId } from "@/lib/utils"; + +// 권한그룹의 메뉴 ON/OFF 토글 +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) return NextResponse.json({ success: false }, { status: 401 }); + + const body = await request.json().catch(() => ({})); + const { masterObjid, menuObjid, on } = body as { masterObjid: string; menuObjid: string; on: boolean }; + if (!masterObjid || !menuObjid) { + return NextResponse.json({ success: false, message: "필수 파라미터 누락" }, { status: 400 }); + } + + try { + if (on) { + const objid = createObjectId(); + await execute( + `INSERT INTO authority_sub_menu (objid, master_objid, menu_objid, writer, regdate) + SELECT $1::numeric, $2::numeric, $3::numeric, $4::text, now() + WHERE NOT EXISTS ( + SELECT 1 FROM authority_sub_menu WHERE master_objid=$2::numeric AND menu_objid=$3::numeric + )`, + [objid, masterObjid, menuObjid, user.userId] + ); + } else { + await execute( + `DELETE FROM authority_sub_menu WHERE master_objid=$1::numeric AND menu_objid=$2::numeric`, + [masterObjid, menuObjid] + ); + } + return NextResponse.json({ success: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[auth/menus/toggle]", msg); + return NextResponse.json({ success: false, message: msg }, { status: 500 }); + } +}