diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index d6f355c4..b984b7c1 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -39,11 +39,25 @@ export const getScreenGroups = async (req: Request, res: Response) => { const countResult = await pool.query(countQuery, params); const total = parseInt(countResult.rows[0].total); - // 데이터 조회 + // 데이터 조회 (screens 배열 포함) const dataQuery = ` SELECT sg.*, - (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count + (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT json_agg( + json_build_object( + 'id', sgs.id, + 'screen_id', sgs.screen_id, + 'screen_name', sd.screen_name, + 'screen_role', sgs.screen_role, + 'display_order', sgs.display_order, + 'is_default', sgs.is_default, + 'table_name', sd.table_name + ) ORDER BY sgs.display_order + ) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id + ) as screens FROM screen_groups sg ${whereClause} ORDER BY sg.display_order ASC, sg.created_date DESC @@ -84,7 +98,8 @@ export const getScreenGroup = async (req: Request, res: Response) => { 'screen_name', sd.screen_name, 'screen_role', sgs.screen_role, 'display_order', sgs.display_order, - 'is_default', sgs.is_default + 'is_default', sgs.is_default, + 'table_name', sd.table_name ) ORDER BY sgs.display_order ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id @@ -981,3 +996,109 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response } }; +// ============================================================ +// 화면 서브 테이블 관계 조회 (조인/참조 테이블) +// ============================================================ + +// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계) +export const getScreenSubTables = async (req: Request, res: Response) => { + try { + const { screenIds } = req.body; + + if (!screenIds || !Array.isArray(screenIds) || screenIds.length === 0) { + return res.status(400).json({ success: false, message: "screenIds 배열이 필요합니다." }); + } + + // 화면별 메인 테이블과 서브 테이블 관계 조회 + // componentConfig에서 tableName, sourceTable 추출 + const query = ` + SELECT DISTINCT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + COALESCE( + sl.properties->'componentConfig'->>'tableName', + sl.properties->'componentConfig'->>'sourceTable' + ) as sub_table, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->>'targetTable' as target_table + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND ( + sl.properties->'componentConfig'->>'tableName' IS NOT NULL + OR sl.properties->'componentConfig'->>'sourceTable' IS NOT NULL + ) + ORDER BY sd.screen_id + `; + + const result = await pool.query(query, [screenIds]); + + // 화면별 서브 테이블 그룹화 + const screenSubTables: Record; + }> = {}; + + result.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const subTable = row.sub_table; + + // 메인 테이블과 동일한 경우 제외 + if (!subTable || subTable === mainTable) { + return; + } + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + // 중복 체크 + const exists = screenSubTables[screenId].subTables.some( + (st) => st.tableName === subTable + ); + + if (!exists) { + // 관계 타입 추론 + let relationType = 'lookup'; + const componentType = row.component_type || ''; + if (componentType.includes('autocomplete') || componentType.includes('entity-search')) { + relationType = 'lookup'; + } else if (componentType.includes('modal-repeater') || componentType.includes('selected-items')) { + relationType = 'source'; + } else if (componentType.includes('table')) { + relationType = 'join'; + } + + screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: componentType, + relationType: relationType, + }); + } + }); + + logger.info("화면 서브 테이블 정보 조회", { screenIds, resultCount: Object.keys(screenSubTables).length }); + + res.json({ + success: true, + data: screenSubTables, + }); + } catch (error: any) { + logger.error("화면 서브 테이블 정보 조회 실패:", error); + res.status(500).json({ success: false, message: "화면 서브 테이블 정보 조회에 실패했습니다.", error: error.message }); + } +}; + diff --git a/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts index 1f5fde05..d4980fe8 100644 --- a/backend-node/src/routes/screenGroupRoutes.ts +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -29,6 +29,8 @@ import { // 화면 레이아웃 요약 getScreenLayoutSummary, getMultipleScreenLayoutSummary, + // 화면 서브 테이블 관계 + getScreenSubTables, } from "../controllers/screenGroupController"; const router = Router(); @@ -82,6 +84,11 @@ router.delete("/table-relations/:id", deleteTableRelation); router.get("/layout-summary/:screenId", getScreenLayoutSummary); router.post("/layout-summary/batch", getMultipleScreenLayoutSummary); +// ============================================================ +// 화면 서브 테이블 관계 (조인/참조 테이블) +// ============================================================ +router.post("/sub-tables/batch", getScreenSubTables); + export default router; diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 482aaa2c..5b996ea3 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -23,6 +23,8 @@ type ViewMode = "tree" | "table"; export default function ScreenManagementPage() { const [currentStep, setCurrentStep] = useState("list"); const [selectedScreen, setSelectedScreen] = useState(null); + const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string } | null>(null); + const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState(null); const [stepHistory, setStepHistory] = useState(["list"]); const [viewMode, setViewMode] = useState("tree"); const [screens, setScreens] = useState([]); @@ -68,9 +70,10 @@ export default function ScreenManagementPage() { } }; - // 화면 선택 핸들러 + // 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제) const handleScreenSelect = (screen: ScreenDefinition) => { setSelectedScreen(screen); + setSelectedGroup(null); // 그룹 선택 해제 }; // 화면 디자인 핸들러 @@ -170,13 +173,28 @@ export default function ScreenManagementPage() { selectedScreen={selectedScreen} onScreenSelect={handleScreenSelect} onScreenDesign={handleDesignScreen} + onGroupSelect={(group) => { + setSelectedGroup(group); + setSelectedScreen(null); // 화면 선택 해제 + setFocusedScreenIdInGroup(null); // 포커스 초기화 + }} + onScreenSelectInGroup={(group, screenId) => { + // 그룹 내 화면 클릭 시: 그룹 선택 + 해당 화면 포커스 + setSelectedGroup(group); + setSelectedScreen(null); + setFocusedScreenIdInGroup(screenId); + }} /> {/* 오른쪽: 관계 시각화 (React Flow) */}
- +
) : ( diff --git a/frontend/components/screen/ScreenGroupModal.tsx b/frontend/components/screen/ScreenGroupModal.tsx new file mode 100644 index 00000000..351f3b18 --- /dev/null +++ b/frontend/components/screen/ScreenGroupModal.tsx @@ -0,0 +1,467 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ScreenGroup, createScreenGroup, updateScreenGroup } from "@/lib/api/screenGroup"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown, Folder } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ScreenGroupModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + group?: ScreenGroup | null; // 수정 모드일 때 기존 그룹 데이터 +} + +export function ScreenGroupModal({ + isOpen, + onClose, + onSuccess, + group, +}: ScreenGroupModalProps) { + const [currentCompanyCode, setCurrentCompanyCode] = useState(""); + const [isSuperAdmin, setIsSuperAdmin] = useState(false); + + const [formData, setFormData] = useState({ + group_name: "", + group_code: "", + description: "", + display_order: 0, + target_company_code: "", + parent_group_id: null as number | null, + }); + const [loading, setLoading] = useState(false); + const [companies, setCompanies] = useState<{ code: string; name: string }[]>([]); + const [availableParentGroups, setAvailableParentGroups] = useState([]); + const [isParentGroupSelectOpen, setIsParentGroupSelectOpen] = useState(false); + + // 그룹 경로 가져오기 (계층 구조 표시용) + const getGroupPath = (groupId: number): string => { + const grp = availableParentGroups.find((g) => g.id === groupId); + if (!grp) return ""; + + const path: string[] = [grp.group_name]; + let currentGroup = grp; + + while ((currentGroup as any).parent_group_id) { + const parent = availableParentGroups.find((g) => g.id === (currentGroup as any).parent_group_id); + if (parent) { + path.unshift(parent.group_name); + currentGroup = parent; + } else { + break; + } + } + + return path.join(" > "); + }; + + // 그룹을 계층 구조로 정렬 + const getSortedGroups = (): typeof availableParentGroups => { + const result: typeof availableParentGroups = []; + + const addChildren = (parentId: number | null, level: number) => { + const children = availableParentGroups + .filter((g) => (g as any).parent_group_id === parentId) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + for (const child of children) { + result.push({ ...child, group_level: level } as any); + addChildren(child.id, level + 1); + } + }; + + addChildren(null, 1); + return result; + }; + + // 현재 사용자 정보 로드 + useEffect(() => { + const loadUserInfo = async () => { + try { + const response = await apiClient.get("/auth/me"); + const result = response.data; + if (result.success && result.data) { + const companyCode = result.data.companyCode || result.data.company_code || ""; + setCurrentCompanyCode(companyCode); + setIsSuperAdmin(companyCode === "*"); + } + } catch (error) { + console.error("사용자 정보 로드 실패:", error); + } + }; + + if (isOpen) { + loadUserInfo(); + } + }, [isOpen]); + + // 회사 목록 로드 (최고 관리자만) + useEffect(() => { + if (isSuperAdmin && isOpen) { + const loadCompanies = async () => { + try { + const response = await apiClient.get("/admin/companies"); + const result = response.data; + if (result.success && result.data) { + const companyList = result.data.map((c: any) => ({ + code: c.company_code, + name: c.company_name, + })); + setCompanies(companyList); + } + } catch (error) { + console.error("회사 목록 로드 실패:", error); + } + }; + loadCompanies(); + } + }, [isSuperAdmin, isOpen]); + + // 부모 그룹 목록 로드 (현재 회사의 대분류/중분류 그룹만) + useEffect(() => { + if (isOpen && currentCompanyCode) { + const loadParentGroups = async () => { + try { + const response = await apiClient.get(`/screen-groups/groups?size=1000`); + const result = response.data; + if (result.success && result.data) { + // 모든 그룹을 상위 그룹으로 선택 가능 (무한 중첩 지원) + setAvailableParentGroups(result.data); + } + } catch (error) { + console.error("부모 그룹 목록 로드 실패:", error); + } + }; + loadParentGroups(); + } + }, [isOpen, currentCompanyCode]); + + // 그룹 데이터가 변경되면 폼 초기화 + useEffect(() => { + if (currentCompanyCode) { + if (group) { + setFormData({ + group_name: group.group_name || "", + group_code: group.group_code || "", + description: group.description || "", + display_order: group.display_order || 0, + target_company_code: group.company_code || currentCompanyCode, + parent_group_id: (group as any).parent_group_id || null, + }); + } else { + setFormData({ + group_name: "", + group_code: "", + description: "", + display_order: 0, + target_company_code: currentCompanyCode, + parent_group_id: null, + }); + } + } + }, [group, isOpen, currentCompanyCode]); + + const handleSubmit = async () => { + // 필수 필드 검증 + if (!formData.group_name.trim()) { + toast.error("그룹명을 입력하세요"); + return; + } + if (!formData.group_code.trim()) { + toast.error("그룹 코드를 입력하세요"); + return; + } + + setLoading(true); + try { + let response; + if (group) { + // 수정 모드 + response = await updateScreenGroup(group.id, formData); + } else { + // 추가 모드 + response = await createScreenGroup({ + ...formData, + is_active: "Y", + }); + } + + if (response.success) { + toast.success(group ? "그룹이 수정되었습니다" : "그룹이 추가되었습니다"); + onSuccess(); + onClose(); + } else { + toast.error(response.message || "작업에 실패했습니다"); + } + } catch (error: any) { + console.error("그룹 저장 실패:", error); + toast.error("그룹 저장에 실패했습니다"); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {group ? "그룹 수정" : "그룹 추가"} + + + 화면 그룹 정보를 입력하세요 + + + +
+ {/* 회사 선택 (최고 관리자만) */} + {isSuperAdmin && ( +
+ + +

+ 선택한 회사에 그룹이 생성됩니다 +

+
+ )} + + {/* 부모 그룹 선택 (하위 그룹 만들기) - 트리 구조 + 검색 */} +
+ + + + + + + + + + + 그룹을 찾을 수 없습니다 + + + {/* 대분류로 생성 옵션 */} + { + setFormData({ ...formData, parent_group_id: null }); + setIsParentGroupSelectOpen(false); + }} + className="text-xs sm:text-sm" + > + + + 대분류로 생성 + + {/* 계층 구조로 그룹 표시 */} + {getSortedGroups().map((parentGroup) => ( + { + setFormData({ ...formData, parent_group_id: parentGroup.id }); + setIsParentGroupSelectOpen(false); + }} + className="text-xs sm:text-sm" + > + + {/* 들여쓰기로 계층 표시 */} + + + {parentGroup.group_name} + + + ))} + + + + + +

+ 부모 그룹을 선택하면 하위 그룹으로 생성됩니다 +

+
+ + {/* 그룹명 */} +
+ + + setFormData({ ...formData, group_name: e.target.value }) + } + placeholder="그룹명을 입력하세요" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 그룹 코드 */} +
+ + + setFormData({ ...formData, group_code: e.target.value }) + } + placeholder="영문 대문자와 언더스코어로 입력" + className="h-8 text-xs sm:h-10 sm:text-sm" + disabled={!!group} // 수정 모드일 때는 코드 변경 불가 + /> + {group && ( +

+ 그룹 코드는 수정할 수 없습니다 +

+ )} +
+ + {/* 설명 */} +
+ +