From 5b6313f16f508882a0ea67716b7dbaa1c6967f04 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 30 Jun 2025 08:28:13 +0000 Subject: (대표님) 20250630 16시 - 유저 도메인별 라우터 분리와 보안성검토 대응 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/menu-list/servcie.ts | 239 +++++++++++++++++++++++++ lib/menu-list/table/initialize-button.tsx | 42 +++++ lib/menu-list/table/manager-select.tsx | 192 ++++++++++++++++++++ lib/menu-list/table/menu-list-table.tsx | 280 ++++++++++++++++++++++++++++++ 4 files changed, 753 insertions(+) create mode 100644 lib/menu-list/servcie.ts create mode 100644 lib/menu-list/table/initialize-button.tsx create mode 100644 lib/menu-list/table/manager-select.tsx create mode 100644 lib/menu-list/table/menu-list-table.tsx (limited to 'lib/menu-list') diff --git a/lib/menu-list/servcie.ts b/lib/menu-list/servcie.ts new file mode 100644 index 00000000..35362e6d --- /dev/null +++ b/lib/menu-list/servcie.ts @@ -0,0 +1,239 @@ +// app/evcp/menu-list/actions.ts + +"use server"; + +import db from "@/db/db"; +import { menuAssignments, users } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { mainNav, mainNavVendor, additionalNav, additionalNavVendor } from "@/config/menuConfig"; + +// 메뉴 데이터 타입 정의 +interface MenuData { + menuPath: string; + menuTitle: string; + menuDescription?: string; + menuGroup?: string; + sectionTitle: string; + domain: "evcp" | "partners"; +} + +// Config에서 메뉴 데이터 추출 +function extractMenusFromConfig(): MenuData[] { + const menus: MenuData[] = []; + + // EVCP 메인 네비게이션 + mainNav.forEach(section => { + section.items.forEach(item => { + menus.push({ + menuPath: item.href, + menuTitle: item.title, + menuDescription: item.description, + menuGroup: item.group, + sectionTitle: section.title, + domain: "evcp" + }); + }); + }); + + // EVCP 추가 네비게이션 + additionalNav.forEach(item => { + menus.push({ + menuPath: item.href, + menuTitle: item.title, + menuDescription: item.description, + menuGroup: undefined, + sectionTitle: "추가 메뉴", + domain: "evcp" + }); + }); + + // Partners 메인 네비게이션 + mainNavVendor.forEach(section => { + section.items.forEach(item => { + menus.push({ + menuPath: item.href, + menuTitle: item.title, + menuDescription: item.description, + menuGroup: item.group, + sectionTitle: section.title, + domain: "partners" + }); + }); + }); + + // Partners 추가 네비게이션 + additionalNavVendor.forEach(item => { + menus.push({ + menuPath: item.href, + menuTitle: item.title, + menuDescription: item.description, + menuGroup: undefined, + sectionTitle: "추가 메뉴", + domain: "partners" + }); + }); + + return menus; +} + +// 초기 메뉴 데이터 생성 또는 업데이트 +export async function initializeMenuAssignments() { + try { + const configMenus = extractMenusFromConfig(); + const existingMenus = await db.select().from(menuAssignments); + const existingPaths = new Set(existingMenus.map(m => m.menuPath)); + + // 새로운 메뉴만 추가 + const newMenus = configMenus.filter(menu => !existingPaths.has(menu.menuPath)); + + console.log(newMenus, newMenus) + + if (newMenus.length > 0) { + await db.insert(menuAssignments).values( + newMenus.map(menu => ({ + menuPath: menu.menuPath, + menuTitle: menu.menuTitle, + menuDescription: menu.menuDescription || null, + menuGroup: menu.menuGroup || null, + sectionTitle: menu.sectionTitle, + domain: menu.domain, + isActive: true, + })) + ); + } + + // 기존 메뉴 정보 업데이트 (title, description 등이 변경될 수 있음) + for (const configMenu of configMenus) { + if (existingPaths.has(configMenu.menuPath)) { + await db + .update(menuAssignments) + .set({ + menuTitle: configMenu.menuTitle, + menuDescription: configMenu.menuDescription || null, + menuGroup: configMenu.menuGroup || null, + sectionTitle: configMenu.sectionTitle, + updatedAt: new Date(), + }) + .where(eq(menuAssignments.menuPath, configMenu.menuPath)); + } + } + + revalidatePath("/evcp/menu-list"); + return { success: true, message: `${newMenus.length}개의 새로운 메뉴가 추가되었습니다.` }; + } catch (error) { + console.error("메뉴 초기화 오류:", error); + return { success: false, message: "메뉴 초기화 중 오류가 발생했습니다." }; + } +} + +// 메뉴 담당자 업데이트 +export async function updateMenuManager( + menuPath: string, + manager1Id?: number | null, + manager2Id?: number | null +) { + try { + + console.log(menuPath, manager1Id) + + await db + .update(menuAssignments) + .set({ + manager1Id: manager1Id || null, + manager2Id: manager2Id || null, + updatedAt: new Date(), + }) + .where(eq(menuAssignments.menuPath, menuPath)); + + revalidatePath("/evcp/menu-list"); + return { success: true, message: "담당자가 업데이트되었습니다." }; + } catch (error) { + console.error("담당자 업데이트 오류:", error); + return { success: false, message: "담당자 업데이트 중 오류가 발생했습니다." }; + } +} + +// 메뉴 리스트 조회 (담당자 정보 포함) +export async function getMenuAssignments(domain?: "evcp" | "partners") { + try { + const whereCondition = domain + ? eq(menuAssignments.domain, domain) + : undefined; + + const result = await db + .select({ + id: menuAssignments.id, + menuPath: menuAssignments.menuPath, + menuTitle: menuAssignments.menuTitle, + menuDescription: menuAssignments.menuDescription, + menuGroup: menuAssignments.menuGroup, + sectionTitle: menuAssignments.sectionTitle, + domain: menuAssignments.domain, + isActive: menuAssignments.isActive, + createdAt: menuAssignments.createdAt, + updatedAt: menuAssignments.updatedAt, + manager1Id: menuAssignments.manager1Id, + manager2Id: menuAssignments.manager2Id, + manager1Name: users.name, + manager1Email: users.email, + manager2Name: users.name, + manager2Email: users.email, + }) + .from(menuAssignments) + .leftJoin(users, eq(menuAssignments.manager1Id, users.id)) + .where(whereCondition) + .orderBy(menuAssignments.sectionTitle, menuAssignments.menuGroup, menuAssignments.menuTitle); + + return { success: true, data: result }; + } catch (error) { + console.error("메뉴 조회 오류:", error); + return { success: false, message: "메뉴 조회 중 오류가 발생했습니다.", data: [] }; + } +} + +// 활성 사용자 리스트 조회 +export async function getActiveUsers(domain?: "evcp" | "partners") { + try { + const whereCondition = and( + eq(users.isActive, true), + domain ? eq(users.domain, domain) : undefined + ); + + const result = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + domain: users.domain, + }) + .from(users) + .where(whereCondition) + .orderBy(users.name); + + return { success: true, data: result }; + } catch (error) { + console.error("사용자 조회 오류:", error); + return { success: false, message: "사용자 조회 중 오류가 발생했습니다.", data: [] }; + } +} + +// 메뉴 활성화/비활성화 +export async function toggleMenuActive(menuPath: string, isActive: boolean) { + try { + await db + .update(menuAssignments) + .set({ + isActive, + updatedAt: new Date(), + }) + .where(eq(menuAssignments.menuPath, menuPath)); + + revalidatePath("/evcp/menu-list"); + + return { success: true, message: `메뉴가 ${isActive ? '활성화' : '비활성화'}되었습니다.` }; + } catch (error) { + console.error("메뉴 상태 변경 오류:", error); + return { success: false, message: "메뉴 상태 변경 중 오류가 발생했습니다." }; + } +} \ No newline at end of file diff --git a/lib/menu-list/table/initialize-button.tsx b/lib/menu-list/table/initialize-button.tsx new file mode 100644 index 00000000..1b8a2458 --- /dev/null +++ b/lib/menu-list/table/initialize-button.tsx @@ -0,0 +1,42 @@ +// app/evcp/menu-list/components/initialize-button.tsx + +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { initializeMenuAssignments } from "../servcie"; + +export function InitializeButton() { + const [isLoading, setIsLoading] = useState(false); + + const handleInitialize = async () => { + setIsLoading(true); + try { + const result = await initializeMenuAssignments(); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error("메뉴 초기화 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/lib/menu-list/table/manager-select.tsx b/lib/menu-list/table/manager-select.tsx new file mode 100644 index 00000000..a4bcccd7 --- /dev/null +++ b/lib/menu-list/table/manager-select.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { Check, ChevronsUpDown, Search, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { toast } from "sonner"; +import { updateMenuManager } from "../servcie"; + +interface User { + id: number; + name: string; + email: string; + domain: string; +} + +interface ManagerSelectProps { + menuPath: string; + currentManagerId?: number | null; + users: User[]; + placeholder: string; + otherManagerId?: number | null; // 다른 담당자 ID (중복 선택 방지용) + type: "manager1" | "manager2"; +} + +export function ManagerSelect({ + menuPath, + currentManagerId, + users, + placeholder, + otherManagerId, + type +}: ManagerSelectProps) { + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // 선택 가능한 사용자 필터링 (다른 담당자로 이미 선택된 사용자 제외) + const availableUsers = users.filter(user => + user.id !== otherManagerId || user.id === currentManagerId + ); + + // 현재 선택된 사용자 찾기 + const selectedUser = availableUsers.find(user => user.id === currentManagerId); + + const handleSelect = async (userId: number | null) => { + setIsLoading(true); + setOpen(false); + + try { + // 현재 담당자 정보 구성 + const updateData = type === "manager1" + ? { manager1Id: userId, manager2Id: otherManagerId } + : { manager1Id: otherManagerId, manager2Id: userId }; + + const result = await updateMenuManager( + menuPath, + updateData.manager1Id, + updateData.manager2Id + ); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error("담당자 업데이트 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleClear = () => { + handleSelect(null); + }; + + return ( + + + + )} + + ) : ( + {placeholder} + )} + + + + + + + + + 검색 결과가 없습니다. + + {/* 담당자 없음 옵션 */} + handleSelect(null)} + className="flex items-center gap-2" + > + +
+ 담당자 없음 + + 담당자를 지정하지 않습니다 + +
+
+ + {/* 사용자 목록 */} + {availableUsers.map((user) => ( + handleSelect(user.id)} + className="flex items-center gap-2" + disabled={user.id === otherManagerId && user.id !== currentManagerId} + > + +
+ {user.name} + + {user.email} + +
+ {user.id === otherManagerId && user.id !== currentManagerId && ( + + 다른 담당자로 선택됨 + + )} +
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/lib/menu-list/table/menu-list-table.tsx b/lib/menu-list/table/menu-list-table.tsx new file mode 100644 index 00000000..097be082 --- /dev/null +++ b/lib/menu-list/table/menu-list-table.tsx @@ -0,0 +1,280 @@ +// app/evcp/menu-list/components/menu-list-table.tsx + +"use client"; + +import { useState, useMemo } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Search, Filter, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { ManagerSelect } from "./manager-select"; +import { toggleMenuActive } from "../servcie"; + +interface MenuAssignment { + id: number; + menuPath: string; + menuTitle: string; + menuDescription?: string | null; + menuGroup?: string | null; + sectionTitle: string; + domain: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + manager1Id?: number | null; + manager2Id?: number | null; + manager1Name?: string | null; + manager1Email?: string | null; + manager2Name?: string | null; + manager2Email?: string | null; +} + +interface User { + id: number; + name: string; + email: string; + domain: string; +} + +interface MenuListTableProps { + initialMenus: MenuAssignment[]; + initialUsers: User[]; +} + +export function MenuListTable({ initialMenus, initialUsers }: MenuListTableProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [domainFilter, setDomainFilter] = useState("all"); + const [sectionFilter, setSectionFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + + // 필터링된 메뉴 데이터 + const filteredMenus = useMemo(() => { + return initialMenus.filter((menu) => { + const matchesSearch = + menu.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.sectionTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + (menu.menuDescription?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false); + + const matchesDomain = domainFilter === "all" || menu.domain === domainFilter; + const matchesSection = sectionFilter === "all" || menu.sectionTitle === sectionFilter; + const matchesStatus = statusFilter === "all" || + (statusFilter === "active" && menu.isActive) || + (statusFilter === "inactive" && !menu.isActive); + + return matchesSearch && matchesDomain && matchesSection && matchesStatus; + }); + }, [initialMenus, searchQuery, domainFilter, sectionFilter, statusFilter]); + + // 섹션 리스트 추출 + const sections = useMemo(() => { + const sectionSet = new Set(initialMenus.map(menu => menu.sectionTitle)); + return Array.from(sectionSet).sort(); + }, [initialMenus]); + + // 도메인별 사용자 필터링 + const getFilteredUsers = (domain: string) => { + return initialUsers.filter(user => user.domain === domain); + }; + + // 메뉴 활성화/비활성화 토글 + const handleToggleActive = async (menuPath: string, isActive: boolean) => { + try { + const result = await toggleMenuActive(menuPath, isActive); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error("메뉴 상태 변경 중 오류가 발생했습니다."); + } + }; + + return ( +
+ {/* 필터 영역 */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+
+ +
+ + + + + +
+
+ + {/* 결과 요약 */} +
+ + 총 {filteredMenus.length}개의 메뉴 + {searchQuery && ` (${initialMenus.length}개 중 검색 결과)`} + +
+ + {/* 테이블 */} +
+ + + + 상태 + 메뉴 정보 + 도메인 + 담당자 1 + 담당자 2 + {/* 동작 */} + + + + {filteredMenus.length === 0 ? ( + + + 조건에 맞는 메뉴가 없습니다. + + + ) : ( + filteredMenus.map((menu) => { + const domainUsers = getFilteredUsers(menu.domain); + + return ( + + + handleToggleActive(menu.menuPath, checked)} + /> + + + +
+
+ {menu.menuTitle} + + {menu.sectionTitle} + + {menu.menuGroup && ( + + {menu.menuGroup} + + )} +
+
+ {menu.menuPath} +
+ {menu.menuDescription && ( +
+ {menu.menuDescription} +
+ )} +
+
+ + + + {menu.domain.toUpperCase()} + + + + + + + + + + + + {/* + + */} +
+ ); + }) + )} +
+
+
+
+ ); +} \ No newline at end of file -- cgit v1.2.3