fix: admin-panel 사이드바 동적화 + 사용자/부서 진짜 삭제 기능 + 스펙 v0.2
Deploy momo-erp / deploy (push) Successful in 50s

admin-panel 좌측 메뉴:
- 정적 ADMIN_MENUS 배열 → menu_info [관리자] 트리 동적 로딩
- /api/admin/sidebar-menus: status='active' 자식만 반환
- 메뉴 관리에서 비활성화/삭제하면 사이드바에서 즉시 빠짐
- LABEL_TO_TAB 매핑으로 기존 탭 동작 유지

사용자/부서 삭제:
- /api/admin/users/delete: 진짜 DELETE FROM user_info (+ authority_sub_user 정리, 본인 보호)
- /api/admin/users/deactivate: status=inActive 비활성화 별도
- /api/admin/dept/delete: 진짜 DELETE (자식 부서/소속 사용자 검사)
- /api/admin/dept/deactivate: 비활성화 별도

문서:
- docs/MOMO_DISTRIBUTION_SPEC.md v0.2: 기존 user_info/dept_info/supply_mng/menu_info/comm_code 재사용 정책 명시
- 신규 momo_* 는 모모 비즈니스 도메인(품목/창고/재고/출고/매입) 만 한정
- TODO: momo_users 등 → 기존 테이블로 이전 마이그레이션
This commit is contained in:
chpark
2026-04-26 00:25:04 +09:00
parent bd20680ba2
commit 9d042862f8
7 changed files with 572 additions and 748 deletions
File diff suppressed because it is too large Load Diff
+73 -18
View File
@@ -61,8 +61,46 @@ const ADMIN_MENUS = [
},
];
// 메뉴 라벨 → 탭 키 매핑 (menu_info의 menu_name_kor 와 매칭)
const LABEL_TO_TAB: Record<string, AdminTab> = {
"메뉴관리": "menu",
"권한 관리": "auth",
"부서 관리": "dept",
"사용자 관리": "user",
"공통코드관리": "code",
"공급업체관리": "supply",
"템플릿 관리": "template",
"환율관리": "exchange",
"고객사 관리": "ref-customer",
"재질 관리": "ref-material",
"차종 관리": "ref-car",
"차종 Grade 관리": "ref-car-grade",
"제품군 관리": "ref-product-group",
"제품 관리": "ref-product",
"기술자료 카테고리 관리": "spec-data-category",
"차량옵션(사양) 관리": "car-option",
"파일 다운로드 로그": "log-file",
"로그인 로그": "log-login",
"메일발송 로그": "log-mail",
};
const SECTION_ICONS: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
"메뉴관리": Menu,
"권한 및 사용자 관리": Shield,
"기준정보관리": Database,
"기준정보 관리 (상세)": Settings,
"System Log": Activity,
};
interface SidebarGroup {
objid: string;
label: string;
items: { objid: string; label: string; url: string }[];
}
export default function AdminPanelPage() {
const [activeTab, setActiveTab] = useState<AdminTab>("user");
const [groups, setGroups] = useState<SidebarGroup[]>([]);
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["권한 및 사용자 관리"]));
const toggleSection = (label: string) => {
@@ -74,6 +112,14 @@ export default function AdminPanelPage() {
});
};
// menu_info 기반 동적 사이드바 (status='active' 인 메뉴만)
useEffect(() => {
fetch("/api/admin/sidebar-menus")
.then((r) => r.json())
.then((j) => setGroups(j.groups || []))
.catch(() => setGroups([]));
}, []);
return (
<div className="flex h-screen bg-gray-100">
{/* 좌측 메뉴 (adminMenu.jsp 대응) */}
@@ -85,11 +131,13 @@ export default function AdminPanelPage() {
</h1>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{ADMIN_MENUS.map((section) => {
{groups.length === 0 ? (
<div className="px-3 py-4 text-[11px] text-gray-500"> ...</div>
) : groups.map((section) => {
const isOpen = openSections.has(section.label);
const Icon = section.icon;
const Icon = SECTION_ICONS[section.label] || FileText;
return (
<div key={section.label}>
<div key={section.objid}>
<button
onClick={() => toggleSection(section.label)}
className="w-full flex items-center px-3 py-2 text-[12px] font-semibold hover:bg-white/5 hover:text-white transition-colors"
@@ -100,20 +148,27 @@ export default function AdminPanelPage() {
</button>
{isOpen && (
<div className="py-0.5">
{section.items.map((item) => (
<button
key={item.key}
onClick={() => setActiveTab(item.key)}
className={cn(
"w-full text-left pl-9 pr-3 py-1.5 text-[11px] transition-colors",
activeTab === item.key
? "text-white bg-[#1C90FB]"
: "text-gray-400 hover:text-white hover:bg-white/5"
)}
>
{item.label}
</button>
))}
{section.items.map((item) => {
const tabKey = LABEL_TO_TAB[item.label] as AdminTab | undefined;
const onClick = () => {
if (tabKey) setActiveTab(tabKey);
else if (item.url) window.location.href = item.url;
};
return (
<button
key={item.objid}
onClick={onClick}
className={cn(
"w-full text-left pl-9 pr-3 py-1.5 text-[11px] transition-colors",
tabKey && activeTab === tabKey
? "text-white bg-[#1C90FB]"
: "text-gray-400 hover:text-white hover:bg-white/5"
)}
>
{item.label}
</button>
);
})}
</div>
)}
</div>
@@ -144,7 +199,7 @@ export default function AdminPanelPage() {
{activeTab === "car-option" && <CarOptionManagement />}
{/* 기타 탭은 공통 Placeholder (DB 테이블 없음) */}
{!["user","code","menu","auth","dept","supply","log-login","log-file","log-mail","template","exchange","ref-customer","ref-material","ref-car","ref-product-group","ref-product","spec-data-category","car-option"].includes(activeTab) && (
<PlaceholderContent title={ADMIN_MENUS.flatMap(s => s.items).find(i => i.key === activeTab)?.label || activeTab} />
<PlaceholderContent title={groups.flatMap(g => g.items).find(i => LABEL_TO_TAB[i.label] === activeTab)?.label || activeTab} />
)}
</main>
</div>
@@ -0,0 +1,20 @@
// 부서 비활성화/활성화
import { NextRequest, NextResponse } from "next/server";
import { execute } from "@/lib/db";
import { getSession } from "@/lib/session";
export async function POST(req: NextRequest) {
const user = await getSession();
if (!user) return NextResponse.json({ success: false }, { status: 401 });
const { deptCode, deptCodes, activate } = await req.json() as {
deptCode?: string; deptCodes?: string[]; activate?: boolean;
};
const targets = deptCodes && deptCodes.length > 0 ? deptCodes : (deptCode ? [deptCode] : []);
if (targets.length === 0) {
return NextResponse.json({ success: false, message: "대상 부서를 선택하세요." }, { status: 400 });
}
const ph = targets.map((_, i) => `$${i + 1}`).join(",");
const newStatus = activate ? "active" : "inActive";
await execute(`UPDATE dept_info SET status=$${targets.length + 1} WHERE dept_code IN (${ph})`, [...targets, newStatus]);
return NextResponse.json({ success: true, count: targets.length, status: newStatus });
}
+20 -9
View File
@@ -1,5 +1,6 @@
// 부서 진짜 삭제 (DELETE)
import { NextRequest, NextResponse } from "next/server";
import { execute, queryOne } from "@/lib/db";
import { pool, queryOne } from "@/lib/db";
import { getSession } from "@/lib/session";
export async function POST(req: NextRequest) {
@@ -10,26 +11,36 @@ export async function POST(req: NextRequest) {
if (targets.length === 0) {
return NextResponse.json({ success: false, message: "삭제할 부서를 선택하세요." }, { status: 400 });
}
const ph = targets.map((_, i) => `$${i + 1}`).join(",");
// 자식 부서 검사
const placeholders = targets.map((_, i) => `$${i + 1}`).join(",");
// 자식 부서/소속 사용자 검사 (status 무관, 진짜 row 존재 여부)
const child = await queryOne<{ cnt: string }>(
`SELECT COUNT(*) AS cnt FROM dept_info WHERE parent_dept_code IN (${placeholders}) AND COALESCE(status,'active') != 'inActive'`,
`SELECT COUNT(*) AS cnt FROM dept_info WHERE parent_dept_code IN (${ph})`,
targets
);
if (Number(child?.cnt || 0) > 0) {
return NextResponse.json({ success: false, message: `하위 부서 ${child!.cnt}개가 있어 삭제할 수 없습니다.` }, { status: 400 });
}
// 사용자 검사
const userCnt = await queryOne<{ cnt: string }>(
`SELECT COUNT(*) AS cnt FROM user_info WHERE dept_code IN (${placeholders}) AND COALESCE(status,'active') != 'inActive'`,
`SELECT COUNT(*) AS cnt FROM user_info WHERE dept_code IN (${ph})`,
targets
);
if (Number(userCnt?.cnt || 0) > 0) {
return NextResponse.json({ success: false, message: `소속 사용자 ${userCnt!.cnt}명이 있어 삭제할 수 없습니다.` }, { status: 400 });
}
await execute(`UPDATE dept_info SET status='inActive' WHERE dept_code IN (${placeholders})`, targets);
return NextResponse.json({ success: true, count: targets.length });
const client = await pool.connect();
try {
await client.query("BEGIN");
const r = await client.query(`DELETE FROM dept_info WHERE dept_code IN (${ph})`, targets);
await client.query("COMMIT");
return NextResponse.json({ success: true, deleted: r.rowCount });
} catch (e) {
await client.query("ROLLBACK");
console.error("Dept delete:", e);
const msg = e instanceof Error ? e.message : "삭제 실패";
return NextResponse.json({ success: false, message: msg }, { status: 500 });
} finally {
client.release();
}
}
+44
View File
@@ -0,0 +1,44 @@
// admin-panel 좌측 사이드바 메뉴 — [관리자] 루트 아래 status='active' 자식 트리
import { NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { getSession } from "@/lib/session";
export async function GET() {
const user = await getSession();
if (!user) return NextResponse.json({ success: false }, { status: 401 });
// [관리자] 루트 찾기 (menu_name_kor='관리자' + parent=0)
const rootRows = await queryRows<{ OBJID: string }>(
`SELECT OBJID::text AS "OBJID" FROM menu_info
WHERE PARENT_OBJ_ID = 0 AND MENU_NAME_KOR = '관리자'
LIMIT 1`
);
if (rootRows.length === 0) return NextResponse.json({ groups: [] });
const rootId = rootRows[0].OBJID;
// [관리자] 자식(섹션) + 손자(아이템)
const rows = await queryRows<{ OBJID: string; PARENT_OBJ_ID: string; NAME: string; SEQ: string; URL: string }>(
`SELECT OBJID::text AS "OBJID", PARENT_OBJ_ID::text AS "PARENT_OBJ_ID",
MENU_NAME_KOR AS "NAME", SEQ::text AS "SEQ",
COALESCE(MENU_URL, '') AS "URL"
FROM menu_info
WHERE COALESCE(STATUS, '') = 'active'
AND (PARENT_OBJ_ID = $1::numeric OR PARENT_OBJ_ID IN (
SELECT OBJID FROM menu_info WHERE PARENT_OBJ_ID = $1::numeric AND COALESCE(STATUS,'') = 'active'
))
ORDER BY SEQ ASC`,
[rootId]
);
const sections = rows.filter((r) => r.PARENT_OBJ_ID === rootId);
const items = rows.filter((r) => r.PARENT_OBJ_ID !== rootId);
const groups = sections.map((s) => ({
objid: s.OBJID,
label: s.NAME,
items: items
.filter((i) => i.PARENT_OBJ_ID === s.OBJID)
.map((i) => ({ objid: i.OBJID, label: i.NAME, url: i.URL })),
})).filter((g) => g.items.length > 0);
return NextResponse.json({ groups, rootId });
}
@@ -0,0 +1,20 @@
// 사용자 비활성화 (status='inActive') — 데이터 보존
import { NextRequest, NextResponse } from "next/server";
import { execute } from "@/lib/db";
import { getSession } from "@/lib/session";
export async function POST(req: NextRequest) {
const user = await getSession();
if (!user) return NextResponse.json({ success: false }, { status: 401 });
const { ids, userIds, activate } = await req.json() as {
ids?: string[]; userIds?: string[]; activate?: boolean;
};
const targets = (userIds || ids || []).filter(Boolean);
if (targets.length === 0) {
return NextResponse.json({ success: false, message: "대상 사용자를 선택하세요." }, { status: 400 });
}
const placeholders = targets.map((_, i) => `$${i + 1}`).join(",");
const newStatus = activate ? "active" : "inActive";
await execute(`UPDATE user_info SET status=$${targets.length + 1} WHERE user_id IN (${placeholders})`, [...targets, newStatus]);
return NextResponse.json({ success: true, count: targets.length, status: newStatus });
}
+25 -4
View File
@@ -1,5 +1,6 @@
// 사용자 진짜 삭제 (DELETE) — 관련 매핑까지 정리
import { NextRequest, NextResponse } from "next/server";
import { execute } from "@/lib/db";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/session";
export async function POST(req: NextRequest) {
@@ -10,8 +11,28 @@ export async function POST(req: NextRequest) {
if (targets.length === 0) {
return NextResponse.json({ success: false, message: "삭제할 사용자를 선택하세요." }, { status: 400 });
}
// soft delete (status='inActive') — 안전. 데이터 보존
// 본인 자기 자신 삭제 방지
if (targets.some((id) => String(id) === String(user.userId))) {
return NextResponse.json({ success: false, message: "본인 계정은 삭제할 수 없습니다." }, { status: 400 });
}
const placeholders = targets.map((_, i) => `$${i + 1}`).join(",");
await execute(`UPDATE user_info SET status='inActive' WHERE user_id IN (${placeholders})`, targets);
return NextResponse.json({ success: true, count: targets.length });
const client = await pool.connect();
try {
await client.query("BEGIN");
// 관련 매핑 정리 — FK 충돌 방지
await client.query(`DELETE FROM authority_sub_user WHERE user_id IN (${placeholders})`, targets);
// 실제 삭제
const r = await client.query(`DELETE FROM user_info WHERE user_id IN (${placeholders})`, targets);
await client.query("COMMIT");
return NextResponse.json({ success: true, deleted: r.rowCount });
} catch (e) {
await client.query("ROLLBACK");
console.error("Users delete:", e);
const msg = e instanceof Error ? e.message : "삭제 실패";
return NextResponse.json({ success: false, message: msg }, { status: 500 });
} finally {
client.release();
}
}