fix: admin-panel 사이드바 동적화 + 사용자/부서 진짜 삭제 기능 + 스펙 v0.2
Deploy momo-erp / deploy (push) Successful in 50s
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:
+370
-717
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user