From dca5675009a41027312e3f5cd0d66ad326b107f0 Mon Sep 17 00:00:00 2001 From: chpark Date: Sat, 25 Apr 2026 23:51:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AA=A8=EB=AA=A8=EC=9C=A0=ED=86=B5=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=EB=A5=BC=20[=EC=82=AC=EC=9A=A9=EC=9E=90]=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=95=84=EB=9E=98=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20+=20=EB=A9=94=EB=89=B4=20=EC=82=AD=EC=A0=9C=20casca?= =?UTF-8?q?de=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: menu_info(9000000) parent_obj_id를 0 → -395553955 (사용자) 로 변경 → 상단 [사용자] 탭 안에 '모모유통' 그룹으로 표시 - /api/admin/menus/delete: cascade=true 시 자손까지 재귀 삭제 (rel_menu_auth 동시 정리) - admin-panel 메뉴 관리: 하위 메뉴가 있으면 사용자에게 confirm 후 일괄 삭제 --- src/app/admin-panel/page.tsx | 29 +++++++++++--- src/app/api/admin/menus/delete/route.ts | 50 +++++++++++++++++++------ 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/app/admin-panel/page.tsx b/src/app/admin-panel/page.tsx index 49879b6..ab6ba77 100644 --- a/src/app/admin-panel/page.tsx +++ b/src/app/admin-panel/page.tsx @@ -522,13 +522,30 @@ function MenuManagement() { if (!selected) { Swal.fire("알림", "삭제할 메뉴를 선택하세요.", "warning"); return; } const r = await Swal.fire({ title: "삭제 확인", text: `"${selected.MENU_NAME_KOR}" 메뉴를 삭제하시겠습니까?`, icon: "warning", showCancelButton: true, confirmButtonText: "삭제", cancelButtonText: "취소" }); if (!r.isConfirmed) return; - const res = await fetch("/api/admin/menus/delete", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ objid: selected.OBJID }), - }); - const data = await res.json(); + + const callDelete = async (cascade: boolean) => { + const res = await fetch("/api/admin/menus/delete", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ objid: selected.OBJID, cascade }), + }); + return res.json(); + }; + + let data = await callDelete(false); + if (!data.success && data.needsCascade) { + const r2 = await Swal.fire({ + icon: "warning", + title: `하위 메뉴 ${data.childCount}개 함께 삭제`, + text: "선택한 메뉴와 모든 하위 메뉴를 함께 삭제합니다. 계속하시겠습니까?", + showCancelButton: true, confirmButtonText: "함께 삭제", cancelButtonText: "취소", + confirmButtonColor: "#dc2626", + }); + if (!r2.isConfirmed) return; + data = await callDelete(true); + } + if (data.success) { - Swal.fire({ icon: "success", title: "삭제되었습니다.", timer: 1200, showConfirmButton: false }); + Swal.fire({ icon: "success", title: `${data.deleted ?? 1}개 메뉴 삭제됨`, timer: 1500, showConfirmButton: false }); setSelected(null); setEditForm({}); setShowForm(false); fetchData(); } else { Swal.fire("오류", data.message, "error"); } diff --git a/src/app/api/admin/menus/delete/route.ts b/src/app/api/admin/menus/delete/route.ts index 7b07803..c06d52d 100644 --- a/src/app/api/admin/menus/delete/route.ts +++ b/src/app/api/admin/menus/delete/route.ts @@ -1,29 +1,55 @@ 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(request: NextRequest) { const user = await getSession(); if (!user) return NextResponse.json({ success: false }, { status: 401 }); const body = await request.json(); - const { objid } = body; + const { objid, cascade } = body as { objid?: string | number; cascade?: boolean }; if (!objid) return NextResponse.json({ success: false, message: "삭제할 메뉴를 선택하세요." }); + const client = await pool.connect(); try { - // 하위 메뉴가 있으면 삭제 불가 - const { queryOne } = await import("@/lib/db"); - const child = await queryOne<{ cnt: string }>( - `SELECT COUNT(*) AS cnt FROM menu_info WHERE parent_obj_id = $1`, - [parseInt(objid, 10)] + await client.query("BEGIN"); + const target = Number(objid); + + // 자손 모두 수집 (재귀) + const descendants = await client.query<{ objid: number }>( + `WITH RECURSIVE tree AS ( + SELECT objid FROM menu_info WHERE objid = $1 + UNION ALL + SELECT m.objid FROM menu_info m JOIN tree t ON m.parent_obj_id = t.objid + ) + SELECT objid FROM tree`, + [target] ); - if (Number(child?.cnt || 0) > 0) { - return NextResponse.json({ success: false, message: "하위 메뉴가 있어 삭제할 수 없습니다. 하위 메뉴를 먼저 삭제하세요." }); + + const ids = descendants.rows.map((r) => r.objid); + const childCount = ids.length - 1; + + // cascade 미지정인데 하위 있으면 거부 + if (childCount > 0 && !cascade) { + await client.query("ROLLBACK"); + return NextResponse.json({ + success: false, + message: `하위 메뉴 ${childCount}개가 있습니다. 함께 삭제하려면 다시 시도하세요.`, + childCount, + needsCascade: true, + }); } - await execute(`DELETE FROM menu_info WHERE objid = $1`, [parseInt(objid, 10)]); - return NextResponse.json({ success: true, message: "삭제되었습니다." }); + // 권한-메뉴 매핑 정리 후 메뉴 삭제 + await client.query(`DELETE FROM rel_menu_auth WHERE menu_obj_id = ANY($1::bigint[])`, [ids]); + const r = await client.query(`DELETE FROM menu_info WHERE objid = ANY($1::bigint[])`, [ids]); + await client.query("COMMIT"); + return NextResponse.json({ success: true, deleted: r.rowCount, ids }); } catch (e) { + await client.query("ROLLBACK"); console.error("Menu delete:", e); - return NextResponse.json({ success: false, message: "삭제 중 오류가 발생했습니다." }); + const msg = e instanceof Error ? e.message : "삭제 중 오류"; + return NextResponse.json({ success: false, message: msg }, { status: 500 }); + } finally { + client.release(); } }