feat: 화면 서브 테이블 정보 조회 기능 추가

- 화면 그룹에 대한 서브 테이블 관계를 조회하는 API 및 라우트 구현
- 화면 그룹 목록에서 서브 테이블 정보를 포함하여 데이터 흐름을 시각화
- 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 추가
- 화면 노드 및 관계 시각화 컴포넌트에 서브 테이블 정보 통합
This commit is contained in:
DDD1542 2026-01-05 18:18:26 +09:00
parent 7caf2dea94
commit 6925e3af3f
10 changed files with 2012 additions and 158 deletions

View File

@ -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<number, {
screenId: number;
screenName: string;
mainTable: string;
subTables: Array<{
tableName: string;
componentType: string;
relationType: string; // 'join' | 'lookup' | 'source'
}>;
}> = {};
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 });
}
};

View File

@ -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;

View File

@ -22,6 +22,8 @@ type ViewMode = "tree" | "table";
export default function ScreenManagementPage() {
const [currentStep, setCurrentStep] = useState<Step>("list");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string } | null>(null);
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
const [viewMode, setViewMode] = useState<ViewMode>("tree");
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
@ -67,9 +69,10 @@ export default function ScreenManagementPage() {
}
};
// 화면 선택 핸들러
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
const handleScreenSelect = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
setSelectedGroup(null); // 그룹 선택 해제
};
// 화면 디자인 핸들러
@ -151,13 +154,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);
}}
/>
</div>
</div>
{/* 오른쪽: 관계 시각화 (React Flow) */}
<div className="flex-1 overflow-hidden">
<ScreenRelationFlow screen={selectedScreen} />
<ScreenRelationFlow
screen={selectedScreen}
selectedGroup={selectedGroup}
initialFocusedScreenId={focusedScreenIdInGroup}
/>
</div>
</div>
) : (

View File

@ -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<string>("");
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<ScreenGroup[]>([]);
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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{group ? "그룹 수정" : "그룹 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 회사 선택 (최고 관리자만) */}
{isSuperAdmin && (
<div>
<Label htmlFor="target_company_code" className="text-xs sm:text-sm">
*
</Label>
<Select
value={formData.target_company_code}
onValueChange={(value) =>
setFormData({ ...formData, target_company_code: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="회사를 선택하세요" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.code} value={company.code}>
{company.name} ({company.code})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
)}
{/* 부모 그룹 선택 (하위 그룹 만들기) - 트리 구조 + 검색 */}
<div>
<Label htmlFor="parent_group_id" className="text-xs sm:text-sm">
()
</Label>
<Popover open={isParentGroupSelectOpen} onOpenChange={setIsParentGroupSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isParentGroupSelectOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{formData.parent_group_id === null
? "대분류로 생성"
: getGroupPath(formData.parent_group_id) || "그룹 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput
placeholder="그룹 검색..."
className="text-xs sm:text-sm"
/>
<CommandList>
<CommandEmpty className="text-xs sm:text-sm py-2 text-center">
</CommandEmpty>
<CommandGroup>
{/* 대분류로 생성 옵션 */}
<CommandItem
value="none"
onSelect={() => {
setFormData({ ...formData, parent_group_id: null });
setIsParentGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.parent_group_id === null ? "opacity-100" : "opacity-0"
)}
/>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
</CommandItem>
{/* 계층 구조로 그룹 표시 */}
{getSortedGroups().map((parentGroup) => (
<CommandItem
key={parentGroup.id}
value={`${parentGroup.group_name} ${getGroupPath(parentGroup.id)}`}
onSelect={() => {
setFormData({ ...formData, parent_group_id: parentGroup.id });
setIsParentGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.parent_group_id === parentGroup.id ? "opacity-100" : "opacity-0"
)}
/>
{/* 들여쓰기로 계층 표시 */}
<span
style={{ marginLeft: `${(((parentGroup as any).group_level || 1) - 1) * 16}px` }}
className="flex items-center"
>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
{parentGroup.group_name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 그룹명 */}
<div>
<Label htmlFor="group_name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="group_name"
value={formData.group_name}
onChange={(e) =>
setFormData({ ...formData, group_name: e.target.value })
}
placeholder="그룹명을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 그룹 코드 */}
<div>
<Label htmlFor="group_code" className="text-xs sm:text-sm">
*
</Label>
<Input
id="group_code"
value={formData.group_code}
onChange={(e) =>
setFormData({ ...formData, group_code: e.target.value })
}
placeholder="영문 대문자와 언더스코어로 입력"
className="h-8 text-xs sm:h-10 sm:text-sm"
disabled={!!group} // 수정 모드일 때는 코드 변경 불가
/>
{group && (
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
)}
</div>
{/* 설명 */}
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="그룹에 대한 설명을 입력하세요"
className="min-h-[60px] text-xs sm:min-h-[80px] sm:text-sm"
/>
</div>
{/* 정렬 순서 */}
<div>
<Label htmlFor="display_order" className="text-xs sm:text-sm">
</Label>
<Input
id="display_order"
type="number"
value={formData.display_order}
onChange={(e) =>
setFormData({
...formData,
display_order: parseInt(e.target.value) || 0,
})
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -2,16 +2,86 @@
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { ChevronRight, ChevronDown, Monitor, FolderOpen, Folder } from "lucide-react";
import {
ChevronRight,
ChevronDown,
Monitor,
FolderOpen,
Folder,
Plus,
MoreVertical,
Edit,
Trash2,
FolderInput,
} from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import { ScreenGroup, getScreenGroups } from "@/lib/api/screenGroup";
import {
ScreenGroup,
getScreenGroups,
deleteScreenGroup,
addScreenToGroup,
removeScreenFromGroup,
} from "@/lib/api/screenGroup";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { ScreenGroupModal } from "./ScreenGroupModal";
import { toast } from "sonner";
interface ScreenGroupTreeViewProps {
screens: ScreenDefinition[];
selectedScreen: ScreenDefinition | null;
onScreenSelect: (screen: ScreenDefinition) => void;
onScreenDesign: (screen: ScreenDefinition) => void;
onGroupSelect?: (group: { id: number; name: string } | null) => void;
onScreenSelectInGroup?: (group: { id: number; name: string }, screenId: number) => void;
companyCode?: string;
}
@ -29,6 +99,8 @@ export function ScreenGroupTreeView({
selectedScreen,
onScreenSelect,
onScreenDesign,
onGroupSelect,
onScreenSelectInGroup,
companyCode,
}: ScreenGroupTreeViewProps) {
const [groups, setGroups] = useState<ScreenGroup[]>([]);
@ -36,23 +108,26 @@ export function ScreenGroupTreeView({
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupScreensMap, setGroupScreensMap] = useState<Map<number, number[]>>(new Map());
// 그룹 목록 로드
useEffect(() => {
const loadGroups = async () => {
try {
setLoading(true);
const response = await getScreenGroups({});
if (response.success && response.data) {
setGroups(response.data);
}
} catch (error) {
console.error("그룹 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
// 그룹 모달 상태
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<ScreenGroup | null>(null);
loadGroups();
// 삭제 확인 다이얼로그 상태
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(null);
// 화면 이동 메뉴 상태
const [movingScreen, setMovingScreen] = useState<ScreenDefinition | null>(null);
const [isMoveMenuOpen, setIsMoveMenuOpen] = useState(false);
const [selectedGroupForMove, setSelectedGroupForMove] = useState<number | null>(null);
const [screenRole, setScreenRole] = useState<string>("");
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
const [displayOrder, setDisplayOrder] = useState<number>(1);
// 그룹 목록 및 그룹별 화면 로드
useEffect(() => {
loadGroupsData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [companyCode]);
// 그룹에 속한 화면 ID들을 가져오기
@ -70,18 +145,41 @@ export function ScreenGroupTreeView({
return screens.filter((screen) => !groupedIds.has(screen.screenId));
};
// 그룹에 속한 화면들
// 그룹에 속한 화면들 (display_order 오름차순 정렬)
const getScreensInGroup = (groupId: number): ScreenDefinition[] => {
const screenIds = groupScreensMap.get(groupId) || [];
return screens.filter((screen) => screenIds.includes(screen.screenId));
const group = groups.find((g) => g.id === groupId);
if (!group?.screens) {
const screenIds = groupScreensMap.get(groupId) || [];
return screens.filter((screen) => screenIds.includes(screen.screenId));
}
// 그룹의 screens 배열에서 display_order 정보를 가져와서 정렬
const sortedScreenIds = [...group.screens]
.sort((a, b) => (a.display_order || 999) - (b.display_order || 999))
.map((s) => s.screen_id);
return sortedScreenIds
.map((id) => screens.find((screen) => screen.screenId === id))
.filter((screen): screen is ScreenDefinition => screen !== undefined);
};
const toggleGroup = (groupId: string) => {
const newExpanded = new Set(expandedGroups);
if (newExpanded.has(groupId)) {
newExpanded.delete(groupId);
// 그룹 접으면 선택 해제
if (onGroupSelect) {
onGroupSelect(null);
}
} else {
newExpanded.add(groupId);
// 그룹 펼치면 해당 그룹 선택
if (onGroupSelect && groupId !== "ungrouped") {
const group = groups.find((g) => String(g.id) === groupId);
if (group) {
onGroupSelect({ id: group.id, name: group.group_name });
}
}
}
setExpandedGroups(newExpanded);
};
@ -90,10 +188,215 @@ export function ScreenGroupTreeView({
onScreenSelect(screen);
};
// 그룹 내 화면 클릭 핸들러 (그룹 전체 표시 + 해당 화면 포커스)
const handleScreenClickInGroup = (screen: ScreenDefinition, group: ScreenGroup) => {
if (onScreenSelectInGroup) {
onScreenSelectInGroup(
{ id: group.id, name: group.group_name },
screen.screenId
);
} else {
// fallback: 기존 동작
onScreenSelect(screen);
}
};
const handleScreenDoubleClick = (screen: ScreenDefinition) => {
onScreenDesign(screen);
};
// 그룹 추가 버튼 클릭
const handleAddGroup = () => {
setEditingGroup(null);
setIsGroupModalOpen(true);
};
// 그룹 수정 버튼 클릭
const handleEditGroup = (group: ScreenGroup, e: React.MouseEvent) => {
e.stopPropagation();
setEditingGroup(group);
setIsGroupModalOpen(true);
};
// 그룹 삭제 버튼 클릭
const handleDeleteGroup = (group: ScreenGroup, e: React.MouseEvent) => {
e.stopPropagation();
setDeletingGroup(group);
setIsDeleteDialogOpen(true);
};
// 그룹 삭제 확인
const confirmDeleteGroup = async () => {
if (!deletingGroup) return;
try {
const response = await deleteScreenGroup(deletingGroup.id);
if (response.success) {
toast.success("그룹이 삭제되었습니다");
loadGroupsData();
} else {
toast.error(response.message || "그룹 삭제에 실패했습니다");
}
} catch (error) {
console.error("그룹 삭제 실패:", error);
toast.error("그룹 삭제에 실패했습니다");
} finally {
setIsDeleteDialogOpen(false);
setDeletingGroup(null);
}
};
// 화면 이동 메뉴 열기
const handleMoveScreen = (screen: ScreenDefinition, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setMovingScreen(screen);
// 현재 화면이 속한 그룹 정보 찾기
let currentGroupId: number | null = null;
let currentScreenRole: string = "";
let currentDisplayOrder: number = 1;
// 현재 화면이 속한 그룹 찾기
for (const group of groups) {
if (group.screens && Array.isArray(group.screens)) {
const screenInfo = group.screens.find((s: any) => Number(s.screen_id) === Number(screen.screenId));
if (screenInfo) {
currentGroupId = group.id;
currentScreenRole = screenInfo.screen_role || "";
currentDisplayOrder = screenInfo.display_order || 1;
break;
}
}
}
setSelectedGroupForMove(currentGroupId);
setScreenRole(currentScreenRole);
setDisplayOrder(currentDisplayOrder);
setIsMoveMenuOpen(true);
};
// 화면을 특정 그룹으로 이동
const moveScreenToGroup = async (targetGroupId: number | null) => {
if (!movingScreen) return;
try {
// 현재 그룹에서 제거
const currentGroupId = Array.from(groupScreensMap.entries()).find(([_, screenIds]) =>
screenIds.includes(movingScreen.screenId)
)?.[0];
if (currentGroupId) {
// screen_group_screens에서 해당 연결 찾아서 삭제
const currentGroup = groups.find((g) => g.id === currentGroupId);
if (currentGroup && currentGroup.screens) {
const screenGroupScreen = currentGroup.screens.find(
(s: any) => s.screen_id === movingScreen.screenId
);
if (screenGroupScreen) {
await removeScreenFromGroup(screenGroupScreen.id);
}
}
}
// 새 그룹에 추가 (미분류가 아닌 경우)
if (targetGroupId !== null) {
await addScreenToGroup({
group_id: targetGroupId,
screen_id: movingScreen.screenId,
screen_role: screenRole,
display_order: displayOrder,
is_default: "N",
});
}
toast.success("화면이 이동되었습니다");
loadGroupsData();
} catch (error) {
console.error("화면 이동 실패:", error);
toast.error("화면 이동에 실패했습니다");
} finally {
setIsMoveMenuOpen(false);
setMovingScreen(null);
setSelectedGroupForMove(null);
setScreenRole("");
setDisplayOrder(1);
}
};
// 그룹 경로 가져오기 (계층 구조 표시용)
const getGroupPath = (groupId: number): string => {
const group = groups.find((g) => g.id === groupId);
if (!group) return "";
const path: string[] = [group.group_name];
let currentGroup = group;
while (currentGroup.parent_group_id) {
const parent = groups.find((g) => g.id === currentGroup.parent_group_id);
if (parent) {
path.unshift(parent.group_name);
currentGroup = parent;
} else {
break;
}
}
return path.join(" > ");
};
// 그룹 레벨 가져오기 (들여쓰기용)
const getGroupLevel = (groupId: number): number => {
const group = groups.find((g) => g.id === groupId);
return group?.group_level || 1;
};
// 그룹을 계층 구조로 정렬
const getSortedGroups = (): typeof groups => {
const result: typeof groups = [];
const addChildren = (parentId: number | null, level: number) => {
const children = groups
.filter((g) => g.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 });
addChildren(child.id, level + 1);
}
};
addChildren(null, 1);
return result;
};
// 그룹 데이터 새로고침
const loadGroupsData = async () => {
try {
setLoading(true);
const response = await getScreenGroups({ size: 1000 }); // 모든 그룹 가져오기
if (response.success && response.data) {
setGroups(response.data);
// 각 그룹별 화면 목록 매핑
const screenMap = new Map<number, number[]>();
for (const group of response.data) {
if (group.screens && Array.isArray(group.screens)) {
screenMap.set(
group.id,
group.screens.map((s: any) => s.screen_id)
);
}
}
setGroupScreensMap(screenMap);
}
} catch (error) {
console.error("그룹 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
@ -105,21 +408,40 @@ export function ScreenGroupTreeView({
const ungroupedScreens = getUngroupedScreens();
return (
<div className="h-full overflow-auto">
<div className="p-2">
{/* 그룹화된 화면들 */}
{groups.map((group) => {
const groupId = String(group.id);
const isExpanded = expandedGroups.has(groupId);
const groupScreens = getScreensInGroup(group.id);
<div className="h-full flex flex-col overflow-hidden">
{/* 그룹 추가 버튼 */}
<div className="flex-shrink-0 border-b p-2">
<Button
onClick={handleAddGroup}
variant="outline"
size="sm"
className="w-full gap-2"
>
<Plus className="h-4 w-4" />
</Button>
</div>
return (
<div key={groupId} className="mb-1">
{/* 트리 목록 */}
<div className="flex-1 overflow-auto p-2">
{/* 그룹화된 화면들 (대분류만 먼저 렌더링) */}
{groups
.filter((g) => !(g as any).parent_group_id) // 대분류만 (parent_group_id가 null)
.map((group) => {
const groupId = String(group.id);
const isExpanded = expandedGroups.has(groupId);
const groupScreens = getScreensInGroup(group.id);
// 하위 그룹들 찾기
const childGroups = groups.filter((g) => (g as any).parent_group_id === group.id);
return (
<div key={groupId} className="mb-1">
{/* 그룹 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"text-sm font-medium"
"text-sm font-medium group/item"
)}
onClick={() => toggleGroup(groupId)}
>
@ -133,16 +455,235 @@ export function ScreenGroupTreeView({
) : (
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
)}
<span className="truncate flex-1">{group.groupName}</span>
<span className="truncate flex-1">{group.group_name}</span>
<Badge variant="secondary" className="text-xs">
{groupScreens.length}
</Badge>
{/* 그룹 메뉴 버튼 */}
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover/item:opacity-100"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleEditGroup(group, e as any)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteGroup(group, e as any)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 그룹 내 화면들 */}
{/* 그룹 내 하위 그룹들 */}
{isExpanded && childGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
{childGroups.map((childGroup) => {
const childGroupId = String(childGroup.id);
const isChildExpanded = expandedGroups.has(childGroupId);
const childScreens = getScreensInGroup(childGroup.id);
// 손자 그룹들 (3단계)
const grandChildGroups = groups.filter((g) => (g as any).parent_group_id === childGroup.id);
return (
<div key={childGroupId}>
{/* 중분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"text-xs font-medium group/item"
)}
onClick={() => toggleGroup(childGroupId)}
>
{isChildExpanded ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isChildExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-blue-500" />
) : (
<Folder className="h-3 w-3 shrink-0 text-blue-500" />
)}
<span className="truncate flex-1">{childGroup.group_name}</span>
<Badge variant="secondary" className="text-[10px] h-4">
{childScreens.length}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover/item:opacity-100"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleEditGroup(childGroup, e as any)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteGroup(childGroup, e as any)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 중분류 내 손자 그룹들 (소분류) */}
{isChildExpanded && grandChildGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
{grandChildGroups.map((grandChild) => {
const grandChildId = String(grandChild.id);
const isGrandExpanded = expandedGroups.has(grandChildId);
const grandScreens = getScreensInGroup(grandChild.id);
return (
<div key={grandChildId}>
{/* 소분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"text-xs group/item"
)}
onClick={() => toggleGroup(grandChildId)}
>
{isGrandExpanded ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isGrandExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-green-500" />
) : (
<Folder className="h-3 w-3 shrink-0 text-green-500" />
)}
<span className="truncate flex-1">{grandChild.group_name}</span>
<Badge variant="outline" className="text-[10px] h-4">
{grandScreens.length}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover/item:opacity-100"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleEditGroup(grandChild, e as any)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteGroup(grandChild, e as any)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 소분류 내 화면들 */}
{isGrandExpanded && (
<div className="ml-6 mt-1 space-y-0.5">
{grandScreens.length === 0 ? (
<div className="pl-6 py-2 text-xs text-muted-foreground">
</div>
) : (
grandScreens.map((screen) => (
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
)}
onClick={() => handleScreenClickInGroup(screen, grandChild)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleMoveScreen(screen, e)}
>
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
{screen.screenCode}
</span>
</div>
))
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* 중분류 내 화면들 */}
{isChildExpanded && (
<div className="ml-6 mt-1 space-y-0.5">
{childScreens.length === 0 && grandChildGroups.length === 0 ? (
<div className="pl-6 py-2 text-xs text-muted-foreground">
</div>
) : (
childScreens.map((screen) => (
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
)}
onClick={() => handleScreenClickInGroup(screen, childGroup)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleMoveScreen(screen, e)}
>
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
{screen.screenCode}
</span>
</div>
))
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* 그룹 내 화면들 (대분류 직속) */}
{isExpanded && (
<div className="ml-4 mt-1 space-y-0.5">
{groupScreens.length === 0 ? (
{groupScreens.length === 0 && childGroups.length === 0 ? (
<div className="pl-6 py-2 text-xs text-muted-foreground">
</div>
@ -152,11 +693,12 @@ export function ScreenGroupTreeView({
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent",
"text-sm hover:bg-accent group/screen",
selectedScreen?.screenId === screen.screenId && "bg-accent"
)}
onClick={() => handleScreenClick(screen)}
onClick={() => handleScreenClickInGroup(screen, group)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleMoveScreen(screen, e)}
>
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
@ -206,6 +748,7 @@ export function ScreenGroupTreeView({
)}
onClick={() => handleScreenClick(screen)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleMoveScreen(screen, e)}
>
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
@ -226,7 +769,205 @@ export function ScreenGroupTreeView({
</div>
)}
</div>
{/* 그룹 추가/수정 모달 */}
<ScreenGroupModal
isOpen={isGroupModalOpen}
onClose={() => {
setIsGroupModalOpen(false);
setEditingGroup(null);
}}
onSuccess={loadGroupsData}
group={editingGroup}
/>
{/* 그룹 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{deletingGroup?.group_name}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteGroup}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 화면 이동 메뉴 (다이얼로그) */}
<Dialog open={isMoveMenuOpen} onOpenChange={setIsMoveMenuOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{movingScreen?.screenName}"
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 그룹 선택 (트리 구조 + 검색) */}
<div>
<Label htmlFor="target-group" className="text-xs sm:text-sm">
*
</Label>
<Popover open={isGroupSelectOpen} onOpenChange={setIsGroupSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isGroupSelectOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{selectedGroupForMove === null
? "미분류"
: getGroupPath(selectedGroupForMove) || "그룹 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput
placeholder="그룹 검색..."
className="text-xs sm:text-sm"
/>
<CommandList>
<CommandEmpty className="text-xs sm:text-sm py-2 text-center">
</CommandEmpty>
<CommandGroup>
{/* 미분류 옵션 */}
<CommandItem
value="none"
onSelect={() => {
setSelectedGroupForMove(null);
setIsGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedGroupForMove === null ? "opacity-100" : "opacity-0"
)}
/>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
</CommandItem>
{/* 계층 구조로 그룹 표시 */}
{getSortedGroups().map((group) => (
<CommandItem
key={group.id}
value={`${group.group_name} ${getGroupPath(group.id)}`}
onSelect={() => {
setSelectedGroupForMove(group.id);
setIsGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedGroupForMove === group.id ? "opacity-100" : "opacity-0"
)}
/>
{/* 들여쓰기로 계층 표시 */}
<span
style={{ marginLeft: `${((group.group_level || 1) - 1) * 16}px` }}
className="flex items-center"
>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
{group.group_name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
. .
</p>
</div>
{/* 화면 역할 입력 (그룹이 선택된 경우만) */}
{selectedGroupForMove !== null && (
<>
<div>
<Label htmlFor="screen-role" className="text-xs sm:text-sm">
()
</Label>
<Input
id="screen-role"
value={screenRole}
onChange={(e) => setScreenRole(e.target.value)}
placeholder="예: 목록, 등록, 조회, 팝업..."
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
<div>
<Label htmlFor="display-order" className="text-xs sm:text-sm">
*
</Label>
<Input
id="display-order"
type="number"
value={displayOrder}
onChange={(e) => setDisplayOrder(parseInt(e.target.value) || 1)}
min={1}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
(1: 메인 2: 등록 3: 팝업)
</p>
</div>
</>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setIsMoveMenuOpen(false);
setMovingScreen(null);
setSelectedGroupForMove(null);
setScreenRole("");
setDisplayOrder(1);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={() => moveScreenToGroup(selectedGroupForMove)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
}

View File

@ -26,6 +26,11 @@ export interface ScreenNodeData {
isMain?: boolean;
// 레이아웃 요약 정보 (미리보기용)
layoutSummary?: ScreenLayoutSummary;
// 그룹 내 포커스 관련 속성
isInGroup?: boolean; // 그룹 모드인지
isFocused?: boolean; // 포커스된 화면인지
isFaded?: boolean; // 흑백 처리할지
screenRole?: string; // 화면 역할 (메인그리드, 등록폼 등)
}
// 테이블 노드 데이터 인터페이스
@ -72,6 +77,29 @@ const getScreenTypeColor = (screenType?: string, isMain?: boolean) => {
}
};
// 화면 역할(screenRole)에 따른 색상
const getScreenRoleColor = (screenRole?: string) => {
if (!screenRole) return "bg-slate-400";
// 역할명에 포함된 키워드로 색상 결정
const role = screenRole.toLowerCase();
if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) {
return "bg-violet-500"; // 보라색 - 메인 그리드
}
if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) {
return "bg-blue-500"; // 파란색 - 등록 폼
}
if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) {
return "bg-rose-500"; // 빨간색 - 액션/이벤트
}
if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) {
return "bg-amber-500"; // 주황색 - 상세/팝업
}
return "bg-slate-400"; // 기본 회색
};
// 화면 타입별 라벨
const getScreenTypeLabel = (screenType?: string) => {
switch (screenType) {
@ -88,12 +116,38 @@ const getScreenTypeLabel = (screenType?: string) => {
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const { label, subLabel, isMain, tableName, layoutSummary } = data;
const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data;
const screenType = layoutSummary?.screenType || "form";
const headerColor = getScreenTypeColor(screenType, isMain);
// 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상
// isFocused일 때 색상 활성화, isFaded일 때 회색
let headerColor: string;
if (isInGroup) {
if (isFaded) {
headerColor = "bg-gray-300"; // 흑백 처리 - 더 확실한 회색
} else {
// 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상
headerColor = getScreenRoleColor(screenRole);
}
} else {
headerColor = getScreenTypeColor(screenType, isMain);
}
return (
<div className="group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md transition-all hover:shadow-lg hover:ring-2 hover:ring-primary/20">
<div
className={`group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all cursor-pointer ${
isFocused
? "border-2 border-primary ring-4 ring-primary/50 shadow-xl scale-105"
: isFaded
? "border-gray-200 opacity-50"
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20"
}`}
style={{
filter: isFaded ? "grayscale(100%)" : "none",
transition: "all 0.3s ease",
transform: isFocused ? "scale(1.02)" : "scale(1)",
}}
>
{/* Handles */}
<Handle
type="target"
@ -115,10 +169,10 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
/>
{/* 헤더 (컬러) */}
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor}`}>
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor} transition-colors duration-300`}>
<Monitor className="h-4 w-4" />
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
{isMain && <span className="flex h-2 w-2 rounded-full bg-white/80" />}
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-white/80 animate-pulse" />}
</div>
{/* 화면 미리보기 영역 (컴팩트) */}
@ -160,7 +214,7 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
<div className="text-center text-[9px] text-slate-400 py-2"> </div>
)}
</div>
</div>
</div>
{/* 푸터 (테이블 정보) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
@ -219,7 +273,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
// 그리드 화면 일러스트
if (screenType === "grid") {
return (
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 상단 툴바 */}
<div className="flex items-center gap-2">
@ -296,7 +350,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
<div className="h-10 rounded-md bg-amber-300/80" />
</div>
</div>
<div className="col-span-2 rounded-lg bg-blue-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-12 rounded bg-blue-400" />
<div className="flex h-14 items-end gap-1">
@ -313,7 +367,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
</div>
);
}
@ -323,11 +377,11 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
<div className="rounded-full bg-slate-100 p-4 text-slate-400">
<MousePointer2 className="h-10 w-10" />
</div>
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-blue-500 shadow-sm" />
<div className="h-7 w-16 rounded-md bg-slate-300 shadow-sm" />
</div>
</div>
<div className="text-xs font-medium text-slate-400"> </div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
@ -348,12 +402,15 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
);
};
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 ==========
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ==========
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
const { label, subLabel, isMain, columns } = data;
// 최대 5개 컬럼만 표시
const displayColumns = columns?.slice(0, 5) || [];
const remainingCount = (columns?.length || 0) - 5;
return (
<div className="group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md transition-all hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20">
<div className="group relative flex w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md transition-all hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20">
{/* Handles */}
<Handle
type="target"
@ -373,57 +430,63 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (초록색) */}
<div className={`flex items-center gap-2 px-3 py-2 text-white ${isMain ? "bg-emerald-600" : "bg-slate-500"}`}>
<Database className="h-4 w-4" />
{/* 헤더 (초록색, 컴팩트) */}
<div className={`flex items-center gap-2 px-3 py-1.5 text-white ${isMain ? "bg-emerald-600" : "bg-slate-500"}`}>
<Database className="h-3.5 w-3.5" />
<div className="flex-1 min-w-0">
<div className="truncate text-xs font-semibold">{label}</div>
{subLabel && <div className="truncate text-[10px] opacity-80">{subLabel}</div>}
<div className="truncate text-[11px] font-semibold">{label}</div>
{subLabel && <div className="truncate text-[9px] opacity-80">{subLabel}</div>}
</div>
</div>
{/* 컬럼 목록 */}
<div className="flex-1 overflow-hidden p-2">
{columns && columns.length > 0 ? (
<div className="flex h-full flex-col gap-0.5">
{columns.map((col, idx) => (
{/* 컬럼 목록 (컴팩트) */}
<div className="p-1.5">
{displayColumns.length > 0 ? (
<div className="flex flex-col gap-px">
{displayColumns.map((col, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 rounded bg-slate-50 px-2 py-1 hover:bg-slate-100"
className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5 hover:bg-slate-100"
>
{/* PK/FK 아이콘 */}
{col.isPrimaryKey && <Key className="h-3 w-3 text-amber-500" />}
{col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-3 w-3 text-blue-500" />}
{!col.isPrimaryKey && !col.isForeignKey && <div className="w-3" />}
{col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
{!col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{/* 컬럼명 */}
<span className="flex-1 truncate font-mono text-[10px] font-medium text-slate-700">
<span className="flex-1 truncate font-mono text-[9px] font-medium text-slate-700">
{col.name}
</span>
{/* 타입 */}
<span className="text-[9px] text-slate-400">{col.type}</span>
<span className="text-[8px] text-slate-400">{col.type}</span>
</div>
))}
{columns.length > 8 && (
<div className="text-center text-[9px] text-slate-400">... {columns.length - 8} </div>
{remainingCount > 0 && (
<div className="text-center text-[8px] text-slate-400 py-0.5">+ {remainingCount} </div>
)}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Database className="h-6 w-6 text-slate-300" />
<span className="mt-1 text-[9px] text-slate-400"> </span>
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
<Database className="h-4 w-4 text-slate-300" />
<span className="mt-0.5 text-[8px] text-slate-400"> </span>
</div>
)}
</div>
{/* 푸터 */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
<span className="text-[10px] text-muted-foreground">PostgreSQL</span>
{/* 푸터 (컴팩트) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
<span className="text-[9px] text-muted-foreground">PostgreSQL</span>
{columns && (
<span className="text-[10px] text-muted-foreground">{columns.length} </span>
<span className="text-[9px] text-muted-foreground">{columns.length} </span>
)}
</div>
</div>

View File

@ -11,6 +11,8 @@ import {
useNodesState,
useEdgesState,
MarkerType,
useReactFlow,
ReactFlowProvider,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
@ -21,7 +23,10 @@ import {
getDataFlows,
getTableRelations,
getMultipleScreenLayoutSummary,
getScreenGroup,
getScreenSubTables,
ScreenLayoutSummary,
ScreenSubTablesData,
} from "@/lib/api/screenGroup";
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
@ -33,12 +38,15 @@ const nodeTypes = {
// 레이아웃 상수
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
const TABLE_Y = 400; // 테이블 노드 Y 위치 (하단)
const NODE_WIDTH = 260; // 노드 너비 (조금 넓게)
const TABLE_Y = 520; // 메인 테이블 노드 Y 위치 (중단)
const SUB_TABLE_Y = 780; // 서브 테이블 노드 Y 위치 (하단)
const NODE_WIDTH = 260; // 노드 너비
const NODE_GAP = 40; // 노드 간격
interface ScreenRelationFlowProps {
screen: ScreenDefinition | null;
selectedGroup?: { id: number; name: string } | null;
initialFocusedScreenId?: number | null;
}
// 노드 타입 (Record<string, unknown> 확장)
@ -46,36 +54,57 @@ type ScreenNodeType = Node<ScreenNodeData & Record<string, unknown>>;
type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
type AllNodeType = ScreenNodeType | TableNodeType;
export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
// 내부 컴포넌트 (useReactFlow 사용 가능)
function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }: ScreenRelationFlowProps) {
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [loading, setLoading] = useState(false);
const [tableColumns, setTableColumns] = useState<Record<string, ColumnTypeInfo[]>>({});
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
// 외부에서 전달된 초기 포커스 ID 적용 (화면 이동 없이 강조만)
useEffect(() => {
if (initialFocusedScreenId !== undefined && initialFocusedScreenId !== null) {
setFocusedScreenId(initialFocusedScreenId);
}
}, [initialFocusedScreenId]);
// 화면 ID와 테이블명 매핑 (포커스 시 연결선 강조용)
const [screenTableMap, setScreenTableMap] = useState<Record<number, string>>({});
// 테이블 컬럼 정보 로드
const loadTableColumns = useCallback(
async (tableName: string): Promise<ColumnTypeInfo[]> => {
if (!tableName) return [];
if (tableColumns[tableName]) return tableColumns[tableName];
try {
try {
const response = await getTableColumns(tableName);
if (response.success && response.data && response.data.columns) {
const columns = response.data.columns;
setTableColumns((prev) => ({ ...prev, [tableName]: columns }));
return columns;
}
} catch (error) {
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
}
return [];
} catch (error) {
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
}
return [];
},
[tableColumns]
);
// 그룹 변경 시 focusedScreenId 초기화
useEffect(() => {
setFocusedScreenId(null);
}, [selectedGroup?.id, screen?.screenId]);
// 데이터 로드 및 노드/엣지 생성
useEffect(() => {
if (!screen) {
// 그룹도 없고 화면도 없으면 빈 상태
if (!screen && !selectedGroup) {
setNodes([]);
setEdges([]);
return;
@ -83,22 +112,66 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
const loadRelations = async () => {
setLoading(true);
try {
// 관계 데이터 로드
let screenList: ScreenDefinition[] = [];
// ========== 그룹 선택 시: 그룹의 화면들 로드 ==========
if (selectedGroup) {
const groupRes = await getScreenGroup(selectedGroup.id);
if (groupRes.success && groupRes.data) {
const groupData = groupRes.data as any;
const groupScreens = groupData.screens || [];
// display_order 순으로 정렬
groupScreens.sort((a: any, b: any) => (a.display_order || 0) - (b.display_order || 0));
// screen_definitions 형식으로 변환 (table_name 포함)
screenList = groupScreens.map((gs: any) => ({
screenId: gs.screen_id,
screenName: gs.screen_name || `화면 ${gs.screen_id}`,
screenCode: gs.screen_code || "",
tableName: gs.table_name || "", // 테이블명 포함
companyCode: groupData.company_code,
isActive: "Y",
createdDate: new Date(),
updatedDate: new Date(),
screenRole: gs.screen_role, // screen_role 추가
displayOrder: gs.display_order, // display_order 추가
} as ScreenDefinition & { screenRole?: string; displayOrder?: number }));
}
} else if (screen) {
// 기존 방식: 선택된 화면 중심
screenList = [screen];
}
if (screenList.length === 0) {
setNodes([]);
setEdges([]);
setLoading(false);
return;
}
// 화면-테이블 매핑 저장 (포커스 시 연결선 강조용)
const newScreenTableMap: Record<number, string> = {};
screenList.forEach((scr: any) => {
if (scr.tableName) {
newScreenTableMap[scr.screenId] = scr.tableName;
}
});
setScreenTableMap(newScreenTableMap);
// 관계 데이터 로드 (첫 번째 화면 기준)
const [joinsRes, flowsRes, relationsRes] = await Promise.all([
getFieldJoins(screen.screenId).catch(() => ({ success: false, data: [] })),
getFieldJoins(screenList[0].screenId).catch(() => ({ success: false, data: [] })),
getDataFlows().catch(() => ({ success: false, data: [] })),
getTableRelations({ screen_id: screen.screenId }).catch(() => ({ success: false, data: [] })),
getTableRelations({ screen_id: screenList[0].screenId }).catch(() => ({ success: false, data: [] })),
]);
const joins = joinsRes.success ? joinsRes.data || [] : [];
const flows = flowsRes.success ? flowsRes.data || [] : [];
const relations = relationsRes.success ? relationsRes.data || [] : [];
// ========== 화면 목록 수집 ==========
const screenList: ScreenDefinition[] = [screen];
// 데이터 흐름에서 연결된 화면들 추가
flows.forEach((flow: any) => {
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
@ -121,23 +194,61 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
// 화면 레이아웃 요약 정보 로드
const screenIds = screenList.map((s) => s.screenId);
let layoutSummaries: Record<number, ScreenLayoutSummary> = {};
let subTablesData: Record<number, ScreenSubTablesData> = {};
try {
const layoutRes = await getMultipleScreenLayoutSummary(screenIds);
// 레이아웃 요약과 서브 테이블 정보 병렬 로드
const [layoutRes, subTablesRes] = await Promise.all([
getMultipleScreenLayoutSummary(screenIds),
getScreenSubTables(screenIds),
]);
if (layoutRes.success && layoutRes.data) {
// API 응답이 Record 형태 (screenId -> summary)
layoutSummaries = layoutRes.data as Record<number, ScreenLayoutSummary>;
}
if (subTablesRes.success && subTablesRes.data) {
subTablesData = subTablesRes.data as Record<number, ScreenSubTablesData>;
}
} catch (e) {
console.error("레이아웃 요약 로드 실패:", e);
console.error("레이아웃 요약/서브 테이블 로드 실패:", e);
}
// ========== 상단: 화면 노드들 ==========
const screenNodes: ScreenNodeType[] = [];
const screenStartX = 50;
// screen_role 레이블 매핑
const getRoleLabel = (role?: string) => {
if (!role || role === "member") return "화면";
const roleMap: Record<string, string> = {
main_list: "메인 그리드",
register_form: "등록 폼",
popup: "팝업",
detail: "상세",
};
return roleMap[role] || role;
};
screenList.forEach((scr, idx) => {
const isMain = scr.screenId === screen.screenId;
screenList.forEach((scr: any, idx) => {
const isMain = screen && scr.screenId === screen.screenId;
const summary = layoutSummaries[scr.screenId];
const roleLabel = getRoleLabel(scr.screenRole);
// 포커스 여부 결정 (그룹 모드 & 개별 화면 모드 모두 지원)
const isInGroup = !!selectedGroup;
let isFocused: boolean;
let isFaded: boolean;
if (isInGroup) {
// 그룹 모드: 클릭한 화면만 포커스
isFocused = focusedScreenId === scr.screenId;
isFaded = focusedScreenId !== null && !isFocused;
} else {
// 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게
isFocused = isMain;
isFaded = !isMain && screenList.length > 1;
}
screenNodes.push({
id: `screen-${scr.screenId}`,
@ -145,43 +256,72 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
position: { x: screenStartX + idx * (NODE_WIDTH + NODE_GAP), y: SCREEN_Y },
data: {
label: scr.screenName,
subLabel: isMain ? "메인 화면" : "연결 화면",
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
type: "screen",
isMain,
isMain: selectedGroup ? idx === 0 : isMain,
tableName: scr.tableName,
layoutSummary: summary,
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
isInGroup,
isFocused,
isFaded,
screenRole: scr.screenRole,
},
});
});
// ========== 하단: 테이블 노드들 ==========
// ========== 중단: 메인 테이블 노드들 ==========
const tableNodes: TableNodeType[] = [];
const tableSet = new Set<string>();
const mainTableSet = new Set<string>();
const subTableSet = new Set<string>();
// 메인 화면의 테이블 추가
if (screen.tableName) {
tableSet.add(screen.tableName);
}
// 모든 화면의 메인 테이블 추가
screenList.forEach((scr) => {
if (scr.tableName) {
mainTableSet.add(scr.tableName);
}
});
// 조인된 테이블들 추가
// 조인된 테이블들 (screen_field_joins에서)
joins.forEach((join: any) => {
if (join.save_table) tableSet.add(join.save_table);
if (join.join_table) tableSet.add(join.join_table);
if (join.save_table) mainTableSet.add(join.save_table);
if (join.join_table) mainTableSet.add(join.join_table);
});
// 테이블 관계에서 추가
relations.forEach((rel: any) => {
if (rel.table_name) tableSet.add(rel.table_name);
if (rel.table_name) mainTableSet.add(rel.table_name);
});
// 서브 테이블 수집 (componentConfig에서 추출된 테이블들)
// 서브 테이블은 메인 테이블과 다른 테이블들
Object.values(subTablesData).forEach((screenSubData) => {
screenSubData.subTables.forEach((subTable) => {
// 메인 테이블에 없는 것만 서브 테이블로 추가
if (!mainTableSet.has(subTable.tableName)) {
subTableSet.add(subTable.tableName);
}
});
});
// 테이블 노드 배치 (하단, 가로 배치)
const tableList = Array.from(tableSet);
const tableStartX = 50;
for (let idx = 0; idx < tableList.length; idx++) {
const tableName = tableList[idx];
const isMainTable = tableName === screen.tableName;
// 메인 테이블 노드 배치 (화면들의 중앙 아래에 배치)
const mainTableList = Array.from(mainTableSet);
// 화면 노드들의 총 너비 계산
const screenTotalWidth = screenList.length * NODE_WIDTH + (screenList.length - 1) * NODE_GAP;
const screenCenterX = screenStartX + screenTotalWidth / 2;
// 메인 테이블 노드들의 총 너비 계산
const mainTableTotalWidth = mainTableList.length * NODE_WIDTH + (mainTableList.length - 1) * NODE_GAP;
const mainTableStartX = screenCenterX - mainTableTotalWidth / 2;
// 첫 번째 화면의 테이블 또는 선택된 화면의 테이블
const primaryTableName = screen?.tableName || (screenList.length > 0 ? screenList[0].tableName : null);
for (let idx = 0; idx < mainTableList.length; idx++) {
const tableName = mainTableList[idx];
const isPrimaryTable = tableName === primaryTableName;
// 컬럼 정보 로드
let columns: ColumnTypeInfo[] = [];
try {
@ -201,33 +341,157 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
tableNodes.push({
id: `table-${tableName}`,
type: "tableNode",
position: { x: tableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y },
position: { x: mainTableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y },
data: {
label: tableName,
subLabel: isMainTable ? "메인 테이블" : "조인 테이블",
isMain: isMainTable,
subLabel: isPrimaryTable ? "메인 테이블" : "조인 테이블",
isMain: isPrimaryTable,
columns: formattedColumns,
},
});
}
// ========== 하단: 서브 테이블 노드들 (참조/조회용) ==========
const subTableList = Array.from(subTableSet);
if (subTableList.length > 0) {
// 서브 테이블 노드들의 총 너비 계산
const subTableTotalWidth = subTableList.length * NODE_WIDTH + (subTableList.length - 1) * NODE_GAP;
const subTableStartX = screenCenterX - subTableTotalWidth / 2;
for (let idx = 0; idx < subTableList.length; idx++) {
const tableName = subTableList[idx];
// 컬럼 정보 로드
let columns: ColumnTypeInfo[] = [];
try {
columns = await loadTableColumns(tableName);
} catch (e) {
// ignore
}
// 컬럼 정보를 PK/FK 표시와 함께 변환
const formattedColumns = columns.slice(0, 5).map((col) => ({
name: col.displayName || col.columnName || "",
type: col.dataType || "",
isPrimaryKey: col.isPrimaryKey || col.columnName === "id",
isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"),
}));
// 서브 테이블의 관계 타입 결정
let relationType = "참조";
Object.values(subTablesData).forEach((screenSubData) => {
const matchedSub = screenSubData.subTables.find((st) => st.tableName === tableName);
if (matchedSub) {
if (matchedSub.relationType === "lookup") relationType = "조회";
else if (matchedSub.relationType === "source") relationType = "데이터 소스";
else if (matchedSub.relationType === "join") relationType = "조인";
}
});
tableNodes.push({
id: `subtable-${tableName}`,
type: "tableNode",
position: { x: subTableStartX + idx * (NODE_WIDTH + NODE_GAP), y: SUB_TABLE_Y },
data: {
label: tableName,
subLabel: `서브 테이블 (${relationType})`,
isMain: false,
columns: formattedColumns,
},
});
}
}
// ========== 엣지: 연결선 생성 ==========
const newEdges: Edge[] = [];
// 메인 화면 → 메인 테이블 연결 (양방향 CRUD)
if (screen.tableName) {
newEdges.push({
id: `edge-main`,
source: `screen-${screen.screenId}`,
target: `table-${screen.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
animated: true,
style: { stroke: "#3b82f6", strokeWidth: 2 },
});
// 그룹 선택 시: 화면 간 연결선 (display_order 순)
if (selectedGroup && screenList.length > 1) {
for (let i = 0; i < screenList.length - 1; i++) {
const currentScreen = screenList[i];
const nextScreen = screenList[i + 1];
newEdges.push({
id: `edge-screen-flow-${i}`,
source: `screen-${currentScreen.screenId}`,
target: `screen-${nextScreen.screenId}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
label: `${i + 1}`,
labelStyle: { fontSize: 11, fill: "#0ea5e9", fontWeight: 600 },
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" },
animated: true,
style: { stroke: "#0ea5e9", strokeWidth: 2 },
});
}
}
// 각 화면 → 해당 메인 테이블 연결선 생성 (실선)
screenList.forEach((scr, idx) => {
if (scr.tableName && mainTableSet.has(scr.tableName)) {
const isMain = screen ? scr.screenId === screen.screenId : idx === 0;
newEdges.push({
id: `edge-screen-table-${scr.screenId}`,
source: `screen-${scr.screenId}`,
target: `table-${scr.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
animated: isMain, // 메인 화면만 애니메이션
style: {
stroke: isMain ? "#3b82f6" : "#94a3b8",
strokeWidth: isMain ? 2 : 1.5,
strokeDasharray: isMain ? undefined : "5,5", // 보조 연결은 점선
},
});
}
});
// 메인 테이블 → 서브 테이블 연결선 생성 (점선)
Object.values(subTablesData).forEach((screenSubData) => {
const mainTable = screenSubData.mainTable;
if (!mainTable || !mainTableSet.has(mainTable)) return;
screenSubData.subTables.forEach((subTable) => {
// 서브 테이블 노드가 실제로 생성되었는지 확인
if (!subTableSet.has(subTable.tableName)) return;
// 중복 엣지 방지
const edgeId = `edge-main-sub-${mainTable}-${subTable.tableName}`;
const exists = newEdges.some((e) => e.id === edgeId);
if (exists) return;
// 관계 타입에 따른 라벨
let relationLabel = "참조";
if (subTable.relationType === "lookup") relationLabel = "조회";
else if (subTable.relationType === "source") relationLabel = "데이터 소스";
else if (subTable.relationType === "join") relationLabel = "조인";
newEdges.push({
id: edgeId,
source: `table-${mainTable}`,
target: `subtable-${subTable.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
label: relationLabel,
labelStyle: { fontSize: 9, fill: "#f97316", fontWeight: 500 },
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
labelBgPadding: [3, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#f97316" },
style: {
stroke: "#f97316",
strokeWidth: 1.5,
strokeDasharray: "6,4", // 점선
},
});
});
});
// 조인 관계 엣지 (테이블 간 - 1:N 관계 표시)
joins.forEach((join: any, idx: number) => {
if (join.save_table && join.join_table && join.save_table !== join.join_table) {
@ -256,20 +520,20 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
);
if (!edgeExists) {
newEdges.push({
newEdges.push({
id: `edge-rel-${idx}`,
source: `screen-${screen.screenId}`,
target: `table-${rel.table_name}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
labelStyle: { fontSize: 9, fill: "#10b981" },
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
labelBgPadding: [3, 2] as [number, number],
style: { stroke: "#10b981", strokeWidth: 1.5 },
});
}
style: { stroke: "#10b981", strokeWidth: 1.5 },
});
}
}
});
@ -278,13 +542,13 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
.filter((flow: any) => flow.source_screen_id === screen.screenId)
.forEach((flow: any, idx: number) => {
if (flow.target_screen_id) {
newEdges.push({
newEdges.push({
id: `edge-flow-${idx}`,
source: `screen-${screen.screenId}`,
target: `screen-${flow.target_screen_id}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
animated: true,
label: flow.flow_label || flow.flow_type || "이동",
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
@ -292,9 +556,9 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
style: { stroke: "#8b5cf6", strokeWidth: 2 },
});
}
});
});
}
});
// 최종 노드 배열 합치기
const allNodes: AllNodeType[] = [...screenNodes, ...tableNodes];
@ -324,13 +588,145 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
};
loadRelations();
}, [screen, setNodes, setEdges, loadTableColumns]);
// focusedScreenId는 스타일링에만 영향을 미치므로 의존성에서 제외
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screen, selectedGroup, setNodes, setEdges, loadTableColumns]);
if (!screen) {
// 노드 클릭 핸들러 (그룹 모드에서 화면 포커스) - 조건부 return 전에 선언해야 함
const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
// 그룹 모드가 아니면 무시
if (!selectedGroup) return;
// 화면 노드만 처리
if (node.id.startsWith("screen-")) {
const screenId = parseInt(node.id.replace("screen-", ""));
// 이미 포커스된 화면을 다시 클릭하면 포커스 해제
setFocusedScreenId((prev) => (prev === screenId ? null : screenId));
}
}, [selectedGroup]);
// 포커스에 따른 노드 스타일링 (그룹 모드에서 화면 클릭 시)
const styledNodes = React.useMemo(() => {
// 그룹 모드에서 포커스된 화면이 있을 때만 추가 스타일링
if (!selectedGroup || focusedScreenId === null) return nodes;
return nodes.map((node) => {
if (node.id.startsWith("screen-")) {
const screenId = parseInt(node.id.replace("screen-", ""));
const isFocused = screenId === focusedScreenId;
const isFaded = !isFocused;
return {
...node,
data: {
...node.data,
isFocused,
isFaded,
},
};
}
return node;
});
}, [nodes, selectedGroup, focusedScreenId]);
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
const styledEdges = React.useMemo(() => {
// 개별 화면 모드: 메인 화면의 연결선만 강조
if (!selectedGroup && screen) {
const mainScreenId = screen.screenId;
return edges.map((edge) => {
// 화면 간 연결선
if (edge.source.startsWith("screen-") && edge.target.startsWith("screen-")) {
const sourceId = parseInt(edge.source.replace("screen-", ""));
const targetId = parseInt(edge.target.replace("screen-", ""));
const isConnected = sourceId === mainScreenId || targetId === mainScreenId;
return {
...edge,
animated: isConnected,
style: {
...edge.style,
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
strokeWidth: isConnected ? 2 : 1,
opacity: isConnected ? 1 : 0.3,
},
};
}
// 화면-테이블 연결선
if (edge.source.startsWith("screen-") && edge.target.startsWith("table-")) {
const sourceId = parseInt(edge.source.replace("screen-", ""));
const isMyConnection = sourceId === mainScreenId;
return {
...edge,
animated: isMyConnection,
style: {
...edge.style,
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
strokeWidth: isMyConnection ? 2 : 1,
strokeDasharray: isMyConnection ? undefined : "5,5",
opacity: isMyConnection ? 1 : 0.3,
},
};
}
return edge;
});
}
// 그룹 모드: 포커스된 화면이 없으면 원본 반환
if (!selectedGroup || focusedScreenId === null) return edges;
return edges.map((edge) => {
// 화면 간 연결선 (1, 2, 3 라벨)
if (edge.source.startsWith("screen-") && edge.target.startsWith("screen-")) {
// 포커스된 화면과 연결된 화면 간 선만 활성화
const sourceId = parseInt(edge.source.replace("screen-", ""));
const targetId = parseInt(edge.target.replace("screen-", ""));
const isConnected = sourceId === focusedScreenId || targetId === focusedScreenId;
return {
...edge,
animated: isConnected,
style: {
...edge.style,
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
strokeWidth: isConnected ? 2 : 1,
opacity: isConnected ? 1 : 0.3,
},
};
}
// 화면-테이블 연결선
if (edge.source.startsWith("screen-") && edge.target.startsWith("table-")) {
const sourceId = parseInt(edge.source.replace("screen-", ""));
const isMyConnection = sourceId === focusedScreenId;
return {
...edge,
animated: isMyConnection,
style: {
...edge.style,
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
strokeWidth: isMyConnection ? 2 : 1,
strokeDasharray: isMyConnection ? undefined : "5,5",
opacity: isMyConnection ? 1 : 0.3,
},
};
}
return edge;
});
}, [edges, selectedGroup, focusedScreenId, screen]);
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
if (!screen && !selectedGroup) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<div className="text-center">
<p className="text-sm"> </p>
<p className="text-sm"> </p>
<p className="text-sm"> </p>
</div>
</div>
@ -348,10 +744,11 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
return (
<div className="h-full w-full">
<ReactFlow
nodes={nodes}
edges={edges}
nodes={styledNodes}
edges={styledEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
@ -365,3 +762,12 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
</div>
);
}
// 외부 래퍼 컴포넌트 (ReactFlowProvider 포함)
export function ScreenRelationFlow(props: ScreenRelationFlowProps) {
return (
<ReactFlowProvider>
<ScreenRelationFlowInner {...props} />
</ReactFlowProvider>
);
}

View File

@ -455,3 +455,4 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF
}

View File

@ -407,3 +407,4 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
}

View File

@ -28,6 +28,9 @@ export interface ScreenGroup {
writer?: string;
screen_count?: number;
screens?: ScreenGroupScreen[];
parent_group_id?: number | null; // 상위 그룹 ID
group_level?: number; // 그룹 레벨 (0: 대분류, 1: 중분류, 2: 소분류 ...)
hierarchy_path?: string; // 계층 경로
}
export interface ScreenGroupScreen {
@ -385,3 +388,29 @@ export async function getMultipleScreenLayoutSummary(
}
}
// 서브 테이블 정보 타입
export interface SubTableInfo {
tableName: string;
componentType: string;
relationType: 'lookup' | 'source' | 'join';
}
export interface ScreenSubTablesData {
screenId: number;
screenName: string;
mainTable: string;
subTables: SubTableInfo[];
}
// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계)
export async function getScreenSubTables(
screenIds: number[]
): Promise<ApiResponse<Record<number, ScreenSubTablesData>>> {
try {
const response = await apiClient.post("/screen-groups/sub-tables/batch", { screenIds });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}