feat: 화면 서브 테이블 정보 조회 기능 추가
- 화면 그룹에 대한 서브 테이블 관계를 조회하는 API 및 라우트 구현 - 화면 그룹 목록에서 서브 테이블 정보를 포함하여 데이터 흐름을 시각화 - 프론트엔드에서 화면 선택 시 그룹 및 서브 테이블 정보 연동 기능 추가 - 화면 노드 및 관계 시각화 컴포넌트에 서브 테이블 정보 통합
This commit is contained in:
parent
7caf2dea94
commit
6925e3af3f
|
|
@ -39,11 +39,25 @@ export const getScreenGroups = async (req: Request, res: Response) => {
|
||||||
const countResult = await pool.query(countQuery, params);
|
const countResult = await pool.query(countQuery, params);
|
||||||
const total = parseInt(countResult.rows[0].total);
|
const total = parseInt(countResult.rows[0].total);
|
||||||
|
|
||||||
// 데이터 조회
|
// 데이터 조회 (screens 배열 포함)
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
sg.*,
|
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
|
FROM screen_groups sg
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY sg.display_order ASC, sg.created_date DESC
|
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_name', sd.screen_name,
|
||||||
'screen_role', sgs.screen_role,
|
'screen_role', sgs.screen_role,
|
||||||
'display_order', sgs.display_order,
|
'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
|
) ORDER BY sgs.display_order
|
||||||
) FROM screen_group_screens sgs
|
) FROM screen_group_screens sgs
|
||||||
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import {
|
||||||
// 화면 레이아웃 요약
|
// 화면 레이아웃 요약
|
||||||
getScreenLayoutSummary,
|
getScreenLayoutSummary,
|
||||||
getMultipleScreenLayoutSummary,
|
getMultipleScreenLayoutSummary,
|
||||||
|
// 화면 서브 테이블 관계
|
||||||
|
getScreenSubTables,
|
||||||
} from "../controllers/screenGroupController";
|
} from "../controllers/screenGroupController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -82,6 +84,11 @@ router.delete("/table-relations/:id", deleteTableRelation);
|
||||||
router.get("/layout-summary/:screenId", getScreenLayoutSummary);
|
router.get("/layout-summary/:screenId", getScreenLayoutSummary);
|
||||||
router.post("/layout-summary/batch", getMultipleScreenLayoutSummary);
|
router.post("/layout-summary/batch", getMultipleScreenLayoutSummary);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 화면 서브 테이블 관계 (조인/참조 테이블)
|
||||||
|
// ============================================================
|
||||||
|
router.post("/sub-tables/batch", getScreenSubTables);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ type ViewMode = "tree" | "table";
|
||||||
export default function ScreenManagementPage() {
|
export default function ScreenManagementPage() {
|
||||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
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 [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("tree");
|
const [viewMode, setViewMode] = useState<ViewMode>("tree");
|
||||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
|
|
@ -67,9 +69,10 @@ export default function ScreenManagementPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 선택 핸들러
|
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
|
||||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||||
setSelectedScreen(screen);
|
setSelectedScreen(screen);
|
||||||
|
setSelectedGroup(null); // 그룹 선택 해제
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 디자인 핸들러
|
// 화면 디자인 핸들러
|
||||||
|
|
@ -151,13 +154,28 @@ export default function ScreenManagementPage() {
|
||||||
selectedScreen={selectedScreen}
|
selectedScreen={selectedScreen}
|
||||||
onScreenSelect={handleScreenSelect}
|
onScreenSelect={handleScreenSelect}
|
||||||
onScreenDesign={handleDesignScreen}
|
onScreenDesign={handleDesignScreen}
|
||||||
|
onGroupSelect={(group) => {
|
||||||
|
setSelectedGroup(group);
|
||||||
|
setSelectedScreen(null); // 화면 선택 해제
|
||||||
|
setFocusedScreenIdInGroup(null); // 포커스 초기화
|
||||||
|
}}
|
||||||
|
onScreenSelectInGroup={(group, screenId) => {
|
||||||
|
// 그룹 내 화면 클릭 시: 그룹 선택 + 해당 화면 포커스
|
||||||
|
setSelectedGroup(group);
|
||||||
|
setSelectedScreen(null);
|
||||||
|
setFocusedScreenIdInGroup(screenId);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: 관계 시각화 (React Flow) */}
|
{/* 오른쪽: 관계 시각화 (React Flow) */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<ScreenRelationFlow screen={selectedScreen} />
|
<ScreenRelationFlow
|
||||||
|
screen={selectedScreen}
|
||||||
|
selectedGroup={selectedGroup}
|
||||||
|
initialFocusedScreenId={focusedScreenIdInGroup}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,16 +2,86 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
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 { 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 { 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 {
|
interface ScreenGroupTreeViewProps {
|
||||||
screens: ScreenDefinition[];
|
screens: ScreenDefinition[];
|
||||||
selectedScreen: ScreenDefinition | null;
|
selectedScreen: ScreenDefinition | null;
|
||||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||||
onScreenDesign: (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;
|
companyCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,6 +99,8 @@ export function ScreenGroupTreeView({
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
onScreenSelect,
|
onScreenSelect,
|
||||||
onScreenDesign,
|
onScreenDesign,
|
||||||
|
onGroupSelect,
|
||||||
|
onScreenSelectInGroup,
|
||||||
companyCode,
|
companyCode,
|
||||||
}: ScreenGroupTreeViewProps) {
|
}: ScreenGroupTreeViewProps) {
|
||||||
const [groups, setGroups] = useState<ScreenGroup[]>([]);
|
const [groups, setGroups] = useState<ScreenGroup[]>([]);
|
||||||
|
|
@ -36,23 +108,26 @@ export function ScreenGroupTreeView({
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
const [groupScreensMap, setGroupScreensMap] = useState<Map<number, number[]>>(new Map());
|
const [groupScreensMap, setGroupScreensMap] = useState<Map<number, number[]>>(new Map());
|
||||||
|
|
||||||
// 그룹 목록 로드
|
// 그룹 모달 상태
|
||||||
useEffect(() => {
|
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||||
const loadGroups = async () => {
|
const [editingGroup, setEditingGroup] = useState<ScreenGroup | null>(null);
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await getScreenGroups({});
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setGroups(response.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("그룹 목록 로드 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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]);
|
}, [companyCode]);
|
||||||
|
|
||||||
// 그룹에 속한 화면 ID들을 가져오기
|
// 그룹에 속한 화면 ID들을 가져오기
|
||||||
|
|
@ -70,18 +145,41 @@ export function ScreenGroupTreeView({
|
||||||
return screens.filter((screen) => !groupedIds.has(screen.screenId));
|
return screens.filter((screen) => !groupedIds.has(screen.screenId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 그룹에 속한 화면들
|
// 그룹에 속한 화면들 (display_order 오름차순 정렬)
|
||||||
const getScreensInGroup = (groupId: number): ScreenDefinition[] => {
|
const getScreensInGroup = (groupId: number): ScreenDefinition[] => {
|
||||||
const screenIds = groupScreensMap.get(groupId) || [];
|
const group = groups.find((g) => g.id === groupId);
|
||||||
return screens.filter((screen) => screenIds.includes(screen.screenId));
|
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 toggleGroup = (groupId: string) => {
|
||||||
const newExpanded = new Set(expandedGroups);
|
const newExpanded = new Set(expandedGroups);
|
||||||
if (newExpanded.has(groupId)) {
|
if (newExpanded.has(groupId)) {
|
||||||
newExpanded.delete(groupId);
|
newExpanded.delete(groupId);
|
||||||
|
// 그룹 접으면 선택 해제
|
||||||
|
if (onGroupSelect) {
|
||||||
|
onGroupSelect(null);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
newExpanded.add(groupId);
|
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);
|
setExpandedGroups(newExpanded);
|
||||||
};
|
};
|
||||||
|
|
@ -90,10 +188,215 @@ export function ScreenGroupTreeView({
|
||||||
onScreenSelect(screen);
|
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) => {
|
const handleScreenDoubleClick = (screen: ScreenDefinition) => {
|
||||||
onScreenDesign(screen);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
|
|
@ -105,21 +408,40 @@ export function ScreenGroupTreeView({
|
||||||
const ungroupedScreens = getUngroupedScreens();
|
const ungroupedScreens = getUngroupedScreens();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto">
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
<div className="p-2">
|
{/* 그룹 추가 버튼 */}
|
||||||
{/* 그룹화된 화면들 */}
|
<div className="flex-shrink-0 border-b p-2">
|
||||||
{groups.map((group) => {
|
<Button
|
||||||
const groupId = String(group.id);
|
onClick={handleAddGroup}
|
||||||
const isExpanded = expandedGroups.has(groupId);
|
variant="outline"
|
||||||
const groupScreens = getScreensInGroup(group.id);
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
"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)}
|
onClick={() => toggleGroup(groupId)}
|
||||||
>
|
>
|
||||||
|
|
@ -133,16 +455,235 @@ export function ScreenGroupTreeView({
|
||||||
) : (
|
) : (
|
||||||
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
|
<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">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{groupScreens.length}
|
{groupScreens.length}
|
||||||
</Badge>
|
</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>
|
</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 && (
|
{isExpanded && (
|
||||||
<div className="ml-4 mt-1 space-y-0.5">
|
<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 className="pl-6 py-2 text-xs text-muted-foreground">
|
||||||
화면이 없습니다
|
화면이 없습니다
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -152,11 +693,12 @@ export function ScreenGroupTreeView({
|
||||||
key={screen.screenId}
|
key={screen.screenId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
"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"
|
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClick(screen)}
|
onClick={() => handleScreenClickInGroup(screen, group)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
|
onContextMenu={(e) => handleMoveScreen(screen, e)}
|
||||||
>
|
>
|
||||||
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
||||||
<span className="truncate flex-1">{screen.screenName}</span>
|
<span className="truncate flex-1">{screen.screenName}</span>
|
||||||
|
|
@ -206,6 +748,7 @@ export function ScreenGroupTreeView({
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClick(screen)}
|
onClick={() => handleScreenClick(screen)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
|
onContextMenu={(e) => handleMoveScreen(screen, e)}
|
||||||
>
|
>
|
||||||
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
||||||
<span className="truncate flex-1">{screen.screenName}</span>
|
<span className="truncate flex-1">{screen.screenName}</span>
|
||||||
|
|
@ -226,7 +769,205 @@ export function ScreenGroupTreeView({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ export interface ScreenNodeData {
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
// 레이아웃 요약 정보 (미리보기용)
|
// 레이아웃 요약 정보 (미리보기용)
|
||||||
layoutSummary?: ScreenLayoutSummary;
|
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) => {
|
const getScreenTypeLabel = (screenType?: string) => {
|
||||||
switch (screenType) {
|
switch (screenType) {
|
||||||
|
|
@ -88,12 +116,38 @@ const getScreenTypeLabel = (screenType?: string) => {
|
||||||
|
|
||||||
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
|
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
|
||||||
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
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 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 (
|
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 */}
|
{/* Handles */}
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
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" />
|
<Monitor className="h-4 w-4" />
|
||||||
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
|
<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>
|
</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 className="text-center text-[9px] text-slate-400 py-2">필드 정보 없음</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 푸터 (테이블 정보) */}
|
{/* 푸터 (테이블 정보) */}
|
||||||
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
|
<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") {
|
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 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">
|
<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="rounded-lg bg-amber-100 p-2 shadow-sm">
|
||||||
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
|
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
|
||||||
<div className="h-10 rounded-md bg-amber-300/80" />
|
<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="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="mb-2 h-2.5 w-12 rounded bg-blue-400" />
|
||||||
<div className="flex h-14 items-end gap-1">
|
<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">
|
<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}개
|
{totalComponents}개
|
||||||
</div>
|
</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="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">
|
<div className="rounded-full bg-slate-100 p-4 text-slate-400">
|
||||||
<MousePointer2 className="h-10 w-10" />
|
<MousePointer2 className="h-10 w-10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<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-blue-500 shadow-sm" />
|
||||||
<div className="h-7 w-16 rounded-md bg-slate-300 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="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">
|
<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 }) => {
|
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
const { label, subLabel, isMain, columns } = data;
|
const { label, subLabel, isMain, columns } = data;
|
||||||
|
// 최대 5개 컬럼만 표시
|
||||||
|
const displayColumns = columns?.slice(0, 5) || [];
|
||||||
|
const remainingCount = (columns?.length || 0) - 5;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Handles */}
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
|
|
@ -373,57 +430,63 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
id="right"
|
id="right"
|
||||||
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
|
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"}`}>
|
<div className={`flex items-center gap-2 px-3 py-1.5 text-white ${isMain ? "bg-emerald-600" : "bg-slate-500"}`}>
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-3.5 w-3.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="truncate text-xs font-semibold">{label}</div>
|
<div className="truncate text-[11px] font-semibold">{label}</div>
|
||||||
{subLabel && <div className="truncate text-[10px] opacity-80">{subLabel}</div>}
|
{subLabel && <div className="truncate text-[9px] opacity-80">{subLabel}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 목록 */}
|
{/* 컬럼 목록 (컴팩트) */}
|
||||||
<div className="flex-1 overflow-hidden p-2">
|
<div className="p-1.5">
|
||||||
{columns && columns.length > 0 ? (
|
{displayColumns.length > 0 ? (
|
||||||
<div className="flex h-full flex-col gap-0.5">
|
<div className="flex flex-col gap-px">
|
||||||
{columns.map((col, idx) => (
|
{displayColumns.map((col, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
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 아이콘 */}
|
{/* PK/FK 아이콘 */}
|
||||||
{col.isPrimaryKey && <Key className="h-3 w-3 text-amber-500" />}
|
{col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
|
||||||
{col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-3 w-3 text-blue-500" />}
|
{col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
|
||||||
{!col.isPrimaryKey && !col.isForeignKey && <div className="w-3" />}
|
{!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}
|
{col.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 타입 */}
|
{/* 타입 */}
|
||||||
<span className="text-[9px] text-slate-400">{col.type}</span>
|
<span className="text-[8px] text-slate-400">{col.type}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{columns.length > 8 && (
|
{remainingCount > 0 && (
|
||||||
<div className="text-center text-[9px] text-slate-400">... 외 {columns.length - 8}개 컬럼</div>
|
<div className="text-center text-[8px] text-slate-400 py-0.5">+ {remainingCount}개 더</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
|
||||||
<Database className="h-6 w-6 text-slate-300" />
|
<Database className="h-4 w-4 text-slate-300" />
|
||||||
<span className="mt-1 text-[9px] text-slate-400">컬럼 정보 없음</span>
|
<span className="mt-0.5 text-[8px] text-slate-400">컬럼 정보 없음</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 푸터 */}
|
{/* 푸터 (컴팩트) */}
|
||||||
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
|
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
|
||||||
<span className="text-[10px] text-muted-foreground">PostgreSQL</span>
|
<span className="text-[9px] text-muted-foreground">PostgreSQL</span>
|
||||||
{columns && (
|
{columns && (
|
||||||
<span className="text-[10px] text-muted-foreground">{columns.length}개 컬럼</span>
|
<span className="text-[9px] text-muted-foreground">{columns.length}개 컬럼</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
|
useReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
|
|
||||||
|
|
@ -21,7 +23,10 @@ import {
|
||||||
getDataFlows,
|
getDataFlows,
|
||||||
getTableRelations,
|
getTableRelations,
|
||||||
getMultipleScreenLayoutSummary,
|
getMultipleScreenLayoutSummary,
|
||||||
|
getScreenGroup,
|
||||||
|
getScreenSubTables,
|
||||||
ScreenLayoutSummary,
|
ScreenLayoutSummary,
|
||||||
|
ScreenSubTablesData,
|
||||||
} from "@/lib/api/screenGroup";
|
} from "@/lib/api/screenGroup";
|
||||||
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
|
@ -33,12 +38,15 @@ const nodeTypes = {
|
||||||
|
|
||||||
// 레이아웃 상수
|
// 레이아웃 상수
|
||||||
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
||||||
const TABLE_Y = 400; // 테이블 노드 Y 위치 (하단)
|
const TABLE_Y = 520; // 메인 테이블 노드 Y 위치 (중단)
|
||||||
const NODE_WIDTH = 260; // 노드 너비 (조금 넓게)
|
const SUB_TABLE_Y = 780; // 서브 테이블 노드 Y 위치 (하단)
|
||||||
|
const NODE_WIDTH = 260; // 노드 너비
|
||||||
const NODE_GAP = 40; // 노드 간격
|
const NODE_GAP = 40; // 노드 간격
|
||||||
|
|
||||||
interface ScreenRelationFlowProps {
|
interface ScreenRelationFlowProps {
|
||||||
screen: ScreenDefinition | null;
|
screen: ScreenDefinition | null;
|
||||||
|
selectedGroup?: { id: number; name: string } | null;
|
||||||
|
initialFocusedScreenId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 노드 타입 (Record<string, unknown> 확장)
|
// 노드 타입 (Record<string, unknown> 확장)
|
||||||
|
|
@ -46,36 +54,57 @@ type ScreenNodeType = Node<ScreenNodeData & Record<string, unknown>>;
|
||||||
type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
|
type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
|
||||||
type AllNodeType = ScreenNodeType | TableNodeType;
|
type AllNodeType = ScreenNodeType | TableNodeType;
|
||||||
|
|
||||||
export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
// 내부 컴포넌트 (useReactFlow 사용 가능)
|
||||||
|
function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }: ScreenRelationFlowProps) {
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [tableColumns, setTableColumns] = useState<Record<string, ColumnTypeInfo[]>>({});
|
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(
|
const loadTableColumns = useCallback(
|
||||||
async (tableName: string): Promise<ColumnTypeInfo[]> => {
|
async (tableName: string): Promise<ColumnTypeInfo[]> => {
|
||||||
if (!tableName) return [];
|
if (!tableName) return [];
|
||||||
if (tableColumns[tableName]) return tableColumns[tableName];
|
if (tableColumns[tableName]) return tableColumns[tableName];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getTableColumns(tableName);
|
const response = await getTableColumns(tableName);
|
||||||
if (response.success && response.data && response.data.columns) {
|
if (response.success && response.data && response.data.columns) {
|
||||||
const columns = response.data.columns;
|
const columns = response.data.columns;
|
||||||
setTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
setTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
||||||
return columns;
|
return columns;
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
|
||||||
}
|
}
|
||||||
return [];
|
} catch (error) {
|
||||||
|
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
},
|
},
|
||||||
[tableColumns]
|
[tableColumns]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 그룹 변경 시 focusedScreenId 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
setFocusedScreenId(null);
|
||||||
|
}, [selectedGroup?.id, screen?.screenId]);
|
||||||
|
|
||||||
// 데이터 로드 및 노드/엣지 생성
|
// 데이터 로드 및 노드/엣지 생성
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!screen) {
|
// 그룹도 없고 화면도 없으면 빈 상태
|
||||||
|
if (!screen && !selectedGroup) {
|
||||||
setNodes([]);
|
setNodes([]);
|
||||||
setEdges([]);
|
setEdges([]);
|
||||||
return;
|
return;
|
||||||
|
|
@ -85,20 +114,64 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
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([
|
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: [] })),
|
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 joins = joinsRes.success ? joinsRes.data || [] : [];
|
||||||
const flows = flowsRes.success ? flowsRes.data || [] : [];
|
const flows = flowsRes.success ? flowsRes.data || [] : [];
|
||||||
const relations = relationsRes.success ? relationsRes.data || [] : [];
|
const relations = relationsRes.success ? relationsRes.data || [] : [];
|
||||||
|
|
||||||
// ========== 화면 목록 수집 ==========
|
|
||||||
const screenList: ScreenDefinition[] = [screen];
|
|
||||||
|
|
||||||
// 데이터 흐름에서 연결된 화면들 추가
|
// 데이터 흐름에서 연결된 화면들 추가
|
||||||
flows.forEach((flow: any) => {
|
flows.forEach((flow: any) => {
|
||||||
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
|
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);
|
const screenIds = screenList.map((s) => s.screenId);
|
||||||
let layoutSummaries: Record<number, ScreenLayoutSummary> = {};
|
let layoutSummaries: Record<number, ScreenLayoutSummary> = {};
|
||||||
|
let subTablesData: Record<number, ScreenSubTablesData> = {};
|
||||||
try {
|
try {
|
||||||
const layoutRes = await getMultipleScreenLayoutSummary(screenIds);
|
// 레이아웃 요약과 서브 테이블 정보 병렬 로드
|
||||||
|
const [layoutRes, subTablesRes] = await Promise.all([
|
||||||
|
getMultipleScreenLayoutSummary(screenIds),
|
||||||
|
getScreenSubTables(screenIds),
|
||||||
|
]);
|
||||||
|
|
||||||
if (layoutRes.success && layoutRes.data) {
|
if (layoutRes.success && layoutRes.data) {
|
||||||
// API 응답이 Record 형태 (screenId -> summary)
|
// API 응답이 Record 형태 (screenId -> summary)
|
||||||
layoutSummaries = layoutRes.data as Record<number, ScreenLayoutSummary>;
|
layoutSummaries = layoutRes.data as Record<number, ScreenLayoutSummary>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subTablesRes.success && subTablesRes.data) {
|
||||||
|
subTablesData = subTablesRes.data as Record<number, ScreenSubTablesData>;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("레이아웃 요약 로드 실패:", e);
|
console.error("레이아웃 요약/서브 테이블 로드 실패:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 상단: 화면 노드들 ==========
|
// ========== 상단: 화면 노드들 ==========
|
||||||
const screenNodes: ScreenNodeType[] = [];
|
const screenNodes: ScreenNodeType[] = [];
|
||||||
const screenStartX = 50;
|
const screenStartX = 50;
|
||||||
|
|
||||||
screenList.forEach((scr, idx) => {
|
// screen_role 레이블 매핑
|
||||||
const isMain = scr.screenId === screen.screenId;
|
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: any, idx) => {
|
||||||
|
const isMain = screen && scr.screenId === screen.screenId;
|
||||||
const summary = layoutSummaries[scr.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({
|
screenNodes.push({
|
||||||
id: `screen-${scr.screenId}`,
|
id: `screen-${scr.screenId}`,
|
||||||
|
|
@ -145,42 +256,71 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||||
position: { x: screenStartX + idx * (NODE_WIDTH + NODE_GAP), y: SCREEN_Y },
|
position: { x: screenStartX + idx * (NODE_WIDTH + NODE_GAP), y: SCREEN_Y },
|
||||||
data: {
|
data: {
|
||||||
label: scr.screenName,
|
label: scr.screenName,
|
||||||
subLabel: isMain ? "메인 화면" : "연결 화면",
|
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
|
||||||
type: "screen",
|
type: "screen",
|
||||||
isMain,
|
isMain: selectedGroup ? idx === 0 : isMain,
|
||||||
tableName: scr.tableName,
|
tableName: scr.tableName,
|
||||||
layoutSummary: summary,
|
layoutSummary: summary,
|
||||||
|
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
|
||||||
|
isInGroup,
|
||||||
|
isFocused,
|
||||||
|
isFaded,
|
||||||
|
screenRole: scr.screenRole,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== 하단: 테이블 노드들 ==========
|
// ========== 중단: 메인 테이블 노드들 ==========
|
||||||
const tableNodes: TableNodeType[] = [];
|
const tableNodes: TableNodeType[] = [];
|
||||||
const tableSet = new Set<string>();
|
const mainTableSet = new Set<string>();
|
||||||
|
const subTableSet = new Set<string>();
|
||||||
|
|
||||||
// 메인 화면의 테이블 추가
|
// 모든 화면의 메인 테이블 추가
|
||||||
if (screen.tableName) {
|
screenList.forEach((scr) => {
|
||||||
tableSet.add(screen.tableName);
|
if (scr.tableName) {
|
||||||
}
|
mainTableSet.add(scr.tableName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 조인된 테이블들 추가
|
// 조인된 테이블들 (screen_field_joins에서)
|
||||||
joins.forEach((join: any) => {
|
joins.forEach((join: any) => {
|
||||||
if (join.save_table) tableSet.add(join.save_table);
|
if (join.save_table) mainTableSet.add(join.save_table);
|
||||||
if (join.join_table) tableSet.add(join.join_table);
|
if (join.join_table) mainTableSet.add(join.join_table);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 테이블 관계에서 추가
|
// 테이블 관계에서 추가
|
||||||
relations.forEach((rel: any) => {
|
relations.forEach((rel: any) => {
|
||||||
if (rel.table_name) tableSet.add(rel.table_name);
|
if (rel.table_name) mainTableSet.add(rel.table_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 테이블 노드 배치 (하단, 가로 배치)
|
// 서브 테이블 수집 (componentConfig에서 추출된 테이블들)
|
||||||
const tableList = Array.from(tableSet);
|
// 서브 테이블은 메인 테이블과 다른 테이블들
|
||||||
const tableStartX = 50;
|
Object.values(subTablesData).forEach((screenSubData) => {
|
||||||
|
screenSubData.subTables.forEach((subTable) => {
|
||||||
|
// 메인 테이블에 없는 것만 서브 테이블로 추가
|
||||||
|
if (!mainTableSet.has(subTable.tableName)) {
|
||||||
|
subTableSet.add(subTable.tableName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for (let idx = 0; idx < tableList.length; idx++) {
|
// 메인 테이블 노드 배치 (화면들의 중앙 아래에 배치)
|
||||||
const tableName = tableList[idx];
|
const mainTableList = Array.from(mainTableSet);
|
||||||
const isMainTable = tableName === screen.tableName;
|
|
||||||
|
// 화면 노드들의 총 너비 계산
|
||||||
|
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[] = [];
|
let columns: ColumnTypeInfo[] = [];
|
||||||
|
|
@ -201,33 +341,157 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||||
tableNodes.push({
|
tableNodes.push({
|
||||||
id: `table-${tableName}`,
|
id: `table-${tableName}`,
|
||||||
type: "tableNode",
|
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: {
|
data: {
|
||||||
label: tableName,
|
label: tableName,
|
||||||
subLabel: isMainTable ? "메인 테이블" : "조인 테이블",
|
subLabel: isPrimaryTable ? "메인 테이블" : "조인 테이블",
|
||||||
isMain: isMainTable,
|
isMain: isPrimaryTable,
|
||||||
columns: formattedColumns,
|
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[] = [];
|
const newEdges: Edge[] = [];
|
||||||
|
|
||||||
// 메인 화면 → 메인 테이블 연결 (양방향 CRUD)
|
// 그룹 선택 시: 화면 간 연결선 (display_order 순)
|
||||||
if (screen.tableName) {
|
if (selectedGroup && screenList.length > 1) {
|
||||||
newEdges.push({
|
for (let i = 0; i < screenList.length - 1; i++) {
|
||||||
id: `edge-main`,
|
const currentScreen = screenList[i];
|
||||||
source: `screen-${screen.screenId}`,
|
const nextScreen = screenList[i + 1];
|
||||||
target: `table-${screen.tableName}`,
|
|
||||||
sourceHandle: "bottom",
|
newEdges.push({
|
||||||
targetHandle: "top",
|
id: `edge-screen-flow-${i}`,
|
||||||
type: "smoothstep",
|
source: `screen-${currentScreen.screenId}`,
|
||||||
animated: true,
|
target: `screen-${nextScreen.screenId}`,
|
||||||
style: { stroke: "#3b82f6", strokeWidth: 2 },
|
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 관계 표시)
|
// 조인 관계 엣지 (테이블 간 - 1:N 관계 표시)
|
||||||
joins.forEach((join: any, idx: number) => {
|
joins.forEach((join: any, idx: number) => {
|
||||||
if (join.save_table && join.join_table && join.save_table !== join.join_table) {
|
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}`
|
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
|
||||||
);
|
);
|
||||||
if (!edgeExists) {
|
if (!edgeExists) {
|
||||||
newEdges.push({
|
newEdges.push({
|
||||||
id: `edge-rel-${idx}`,
|
id: `edge-rel-${idx}`,
|
||||||
source: `screen-${screen.screenId}`,
|
source: `screen-${screen.screenId}`,
|
||||||
target: `table-${rel.table_name}`,
|
target: `table-${rel.table_name}`,
|
||||||
sourceHandle: "bottom",
|
sourceHandle: "bottom",
|
||||||
targetHandle: "top",
|
targetHandle: "top",
|
||||||
type: "smoothstep",
|
type: "smoothstep",
|
||||||
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
|
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
|
||||||
labelStyle: { fontSize: 9, fill: "#10b981" },
|
labelStyle: { fontSize: 9, fill: "#10b981" },
|
||||||
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
||||||
labelBgPadding: [3, 2] as [number, number],
|
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)
|
.filter((flow: any) => flow.source_screen_id === screen.screenId)
|
||||||
.forEach((flow: any, idx: number) => {
|
.forEach((flow: any, idx: number) => {
|
||||||
if (flow.target_screen_id) {
|
if (flow.target_screen_id) {
|
||||||
newEdges.push({
|
newEdges.push({
|
||||||
id: `edge-flow-${idx}`,
|
id: `edge-flow-${idx}`,
|
||||||
source: `screen-${screen.screenId}`,
|
source: `screen-${screen.screenId}`,
|
||||||
target: `screen-${flow.target_screen_id}`,
|
target: `screen-${flow.target_screen_id}`,
|
||||||
sourceHandle: "right",
|
sourceHandle: "right",
|
||||||
targetHandle: "left",
|
targetHandle: "left",
|
||||||
type: "smoothstep",
|
type: "smoothstep",
|
||||||
animated: true,
|
animated: true,
|
||||||
label: flow.flow_label || flow.flow_type || "이동",
|
label: flow.flow_label || flow.flow_type || "이동",
|
||||||
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
|
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
|
||||||
|
|
@ -292,9 +556,9 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||||
labelBgPadding: [4, 2] as [number, number],
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
|
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
|
||||||
style: { stroke: "#8b5cf6", strokeWidth: 2 },
|
style: { stroke: "#8b5cf6", strokeWidth: 2 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 최종 노드 배열 합치기
|
// 최종 노드 배열 합치기
|
||||||
const allNodes: AllNodeType[] = [...screenNodes, ...tableNodes];
|
const allNodes: AllNodeType[] = [...screenNodes, ...tableNodes];
|
||||||
|
|
@ -324,13 +588,145 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRelations();
|
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 (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm">화면을 선택하면</p>
|
<p className="text-sm">그룹 또는 화면을 선택하면</p>
|
||||||
<p className="text-sm">데이터 관계가 시각화됩니다</p>
|
<p className="text-sm">데이터 관계가 시각화됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -348,10 +744,11 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={styledNodes}
|
||||||
edges={edges}
|
edges={styledEdges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.2 }}
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
|
@ -365,3 +762,12 @@ export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 외부 래퍼 컴포넌트 (ReactFlowProvider 포함)
|
||||||
|
export function ScreenRelationFlow(props: ScreenRelationFlowProps) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<ScreenRelationFlowInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -455,3 +455,4 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -407,3 +407,4 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ export interface ScreenGroup {
|
||||||
writer?: string;
|
writer?: string;
|
||||||
screen_count?: number;
|
screen_count?: number;
|
||||||
screens?: ScreenGroupScreen[];
|
screens?: ScreenGroupScreen[];
|
||||||
|
parent_group_id?: number | null; // 상위 그룹 ID
|
||||||
|
group_level?: number; // 그룹 레벨 (0: 대분류, 1: 중분류, 2: 소분류 ...)
|
||||||
|
hierarchy_path?: string; // 계층 경로
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScreenGroupScreen {
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue