[권한그룹 사용자 추가 SQL 에러 fix]
- $3 파라미터에 ::text 명시 캐스트로 inconsistent types 해결
("inconsistent types deduced for parameter $3")
[새 UI - admin-panel/auth]
- 좌측: 권한 목록 + 검색 + 생성 (목록에서 클릭으로 활성화, 더블클릭으로 수정/삭제)
- 우중·우우: 권한있는/권한없는 직원 패널 (체크박스 + 전체선택 + 검색)
· ‹ 추가 / 제거 › 버튼 즉시 반영
- 하단: 메뉴 전체 트리 (체크 즉시 서버 반영)
- 모달 헬퍼 안 띄우고 한 화면에서 모두 처리 → 사용 흐름 단순화
[새 스키마/API]
- db/migrations/020_authority_sub_menu.sql
- /api/admin/auth/menus : 그룹의 메뉴 OBJID + 전체 메뉴 트리
- /api/admin/auth/menus/toggle : 단일 메뉴 ON/OFF
[거래명세표]
- 수량 컬럼 너비 w-14 → w-20 (모바일에서 잘리던 문제)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
@@ -577,7 +577,7 @@ function StatementPreview({
|
|||||||
<th className="border border-slate-300 px-1.5 py-1.5 text-left">품명</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 text-left">품명</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5 w-12">구분</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 w-12">구분</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5 w-14 js-no-export">현재고</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 w-14 js-no-export">현재고</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5 w-14">수량</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 w-20">수량</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5 w-20">단가</th>
|
<th className="border border-slate-300 px-1.5 py-1.5 w-20">단가</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5">공급가</th>
|
<th className="border border-slate-300 px-1.5 py-1.5">공급가</th>
|
||||||
<th className="border border-slate-300 px-1.5 py-1.5">세액</th>
|
<th className="border border-slate-300 px-1.5 py-1.5">세액</th>
|
||||||
|
|||||||
@@ -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() {
|
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 [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||||
// 등록/수정 폼
|
// 등록/수정 폼
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export async function POST(request: NextRequest) {
|
|||||||
const objid = createObjectId();
|
const objid = createObjectId();
|
||||||
const rowCount = await execute(
|
const rowCount = await execute(
|
||||||
`INSERT INTO AUTHORITY_SUB_USER (OBJID, MASTER_OBJID, USER_ID, WRITER, REGDATE)
|
`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 (
|
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]
|
[objid, masterObjid, userId, user.userId]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user