feat: 데이터 흐름 조회 기능 개선 및 프리뷰 모드 추가
- 데이터 흐름 조회 API에 source_screen_id 파라미터 추가하여 특정 화면에서 시작하는 데이터 흐름만 조회 가능 - 화면 관리 페이지에서 선택된 그룹에 company_code 필드 추가하여 회사 코드 정보 포함 - 프리뷰 모드에서 URL 쿼리로 company_code를 받아와 데이터 조회 시 우선 사용하도록 로직 개선 - 화면 관계 흐름 및 서브 테이블 정보에서 company_code를 활용하여 필터링 및 시각화 개선
This commit is contained in:
parent
a6569909a2
commit
0773989c74
|
|
@ -603,7 +603,7 @@ export const deleteFieldJoin = async (req: Request, res: Response) => {
|
|||
export const getDataFlows = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = (req.user as any).companyCode;
|
||||
const { group_id } = req.query;
|
||||
const { group_id, source_screen_id } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT sdf.*,
|
||||
|
|
@ -631,6 +631,13 @@ export const getDataFlows = async (req: Request, res: Response) => {
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
// 특정 화면에서 시작하는 데이터 흐름만 조회
|
||||
if (source_screen_id) {
|
||||
query += ` AND sdf.source_screen_id = $${paramIndex}`;
|
||||
params.push(source_screen_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += " ORDER BY sdf.id ASC";
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ type ViewMode = "tree" | "table";
|
|||
export default function ScreenManagementPage() {
|
||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string } | null>(null);
|
||||
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
|
||||
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
|
||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("tree");
|
||||
|
|
|
|||
|
|
@ -32,9 +32,15 @@ function ScreenViewPage() {
|
|||
|
||||
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
||||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||||
|
||||
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
||||
const previewCompanyCode = searchParams.get("company_code");
|
||||
|
||||
// 🆕 현재 로그인한 사용자 정보
|
||||
const { user, userName, companyCode } = useAuth();
|
||||
const { user, userName, companyCode: authCompanyCode } = useAuth();
|
||||
|
||||
// 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용
|
||||
const companyCode = previewCompanyCode || authCompanyCode;
|
||||
|
||||
// 🆕 모바일 환경 감지
|
||||
const { isMobile } = useResponsive();
|
||||
|
|
|
|||
|
|
@ -302,6 +302,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
|
||||
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
||||
|
||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
|
||||
const isPreviewMode = searchParams.get("preview") === "true";
|
||||
|
||||
// 현재 모드에 따라 표시할 메뉴 결정
|
||||
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
|
||||
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
||||
|
|
@ -458,6 +461,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시
|
||||
if (isPreviewMode) {
|
||||
return (
|
||||
<div className="h-screen w-full overflow-auto bg-white p-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// UI 변환된 메뉴 데이터
|
||||
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
||||
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@ interface ScreenGroupTreeViewProps {
|
|||
selectedScreen: ScreenDefinition | null;
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||
onGroupSelect?: (group: { id: number; name: string } | null) => void;
|
||||
onScreenSelectInGroup?: (group: { id: number; name: string }, screenId: number) => void;
|
||||
onGroupSelect?: (group: { id: number; name: string; company_code?: string } | null) => void;
|
||||
onScreenSelectInGroup?: (group: { id: number; name: string; company_code?: string }, screenId: number) => void;
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ export function ScreenGroupTreeView({
|
|||
if (onGroupSelect && groupId !== "ungrouped") {
|
||||
const group = groups.find((g) => String(g.id) === groupId);
|
||||
if (group) {
|
||||
onGroupSelect({ id: group.id, name: group.group_name });
|
||||
onGroupSelect({ id: group.id, name: group.group_name, company_code: group.company_code });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -192,7 +192,7 @@ export function ScreenGroupTreeView({
|
|||
const handleScreenClickInGroup = (screen: ScreenDefinition, group: ScreenGroup) => {
|
||||
if (onScreenSelectInGroup) {
|
||||
onScreenSelectInGroup(
|
||||
{ id: group.id, name: group.group_name },
|
||||
{ id: group.id, name: group.group_name, company_code: group.company_code },
|
||||
screen.screenId
|
||||
);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface FieldMappingDisplay {
|
|||
targetField: string; // 서브 테이블 컬럼 (예: user_id)
|
||||
sourceDisplayName?: string; // 메인 테이블 한글 컬럼명 (예: 담당자)
|
||||
targetDisplayName?: string; // 서브 테이블 한글 컬럼명 (예: 사용자ID)
|
||||
sourceTable?: string; // 소스 테이블명 (필드 매핑에서 테이블 구분용)
|
||||
}
|
||||
|
||||
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const NODE_GAP = 40; // 노드 간격
|
|||
|
||||
interface ScreenRelationFlowProps {
|
||||
screen: ScreenDefinition | null;
|
||||
selectedGroup?: { id: number; name: string } | null;
|
||||
selectedGroup?: { id: number; name: string; company_code?: string } | null;
|
||||
initialFocusedScreenId?: number | null;
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +97,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
screenName: string;
|
||||
tableName?: string;
|
||||
tableLabel?: string;
|
||||
companyCode?: string; // 프리뷰용 회사 코드
|
||||
// 기존 설정 정보 (화면 디자이너에서 추출)
|
||||
existingConfig?: {
|
||||
joinColumnRefs?: Array<{
|
||||
|
|
@ -1114,6 +1115,32 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
};
|
||||
});
|
||||
|
||||
// 화면의 모든 서브 테이블에서 fieldMappings 추출
|
||||
const screenSubTablesData = subTablesDataMap[screenId];
|
||||
const allFieldMappings: Array<{
|
||||
targetField: string;
|
||||
sourceField: string;
|
||||
sourceTable?: string;
|
||||
sourceDisplayName?: string;
|
||||
componentType?: string;
|
||||
}> = [];
|
||||
|
||||
if (screenSubTablesData?.subTables) {
|
||||
screenSubTablesData.subTables.forEach((subTable) => {
|
||||
if (subTable.fieldMappings) {
|
||||
subTable.fieldMappings.forEach((mapping) => {
|
||||
allFieldMappings.push({
|
||||
targetField: mapping.targetField,
|
||||
sourceField: mapping.sourceField,
|
||||
sourceTable: mapping.sourceTable || subTable.tableName,
|
||||
sourceDisplayName: mapping.sourceDisplayName,
|
||||
componentType: subTable.relationType,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSettingModalNode({
|
||||
nodeType: "screen",
|
||||
nodeId: node.id,
|
||||
|
|
@ -1121,10 +1148,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
screenName: nodeData.label || `화면 ${screenId}`,
|
||||
tableName: mainTable,
|
||||
tableLabel: nodeData.subLabel,
|
||||
companyCode: selectedGroup?.company_code, // 프리뷰용 회사 코드
|
||||
// 화면의 테이블 정보 전달
|
||||
existingConfig: {
|
||||
mainTable: mainTable,
|
||||
filterTables: filterTables,
|
||||
fieldMappings: allFieldMappings,
|
||||
},
|
||||
});
|
||||
setIsSettingModalOpen(true);
|
||||
|
|
@ -2232,6 +2261,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
screenId={settingModalNode.screenId}
|
||||
screenName={settingModalNode.screenName}
|
||||
groupId={selectedGroup?.id}
|
||||
companyCode={settingModalNode.companyCode}
|
||||
mainTable={settingModalNode.existingConfig?.mainTable}
|
||||
mainTableLabel={settingModalNode.tableLabel}
|
||||
filterTables={settingModalNode.existingConfig?.filterTables}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ import {
|
|||
Table2,
|
||||
ArrowRight,
|
||||
Settings2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getDataFlows,
|
||||
|
|
@ -62,6 +65,8 @@ import {
|
|||
updateDataFlow,
|
||||
deleteDataFlow,
|
||||
DataFlow,
|
||||
getMultipleScreenLayoutSummary,
|
||||
LayoutItem,
|
||||
} from "@/lib/api/screenGroup";
|
||||
import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement";
|
||||
|
||||
|
|
@ -95,6 +100,7 @@ interface ScreenSettingModalProps {
|
|||
screenId: number;
|
||||
screenName: string;
|
||||
groupId?: number;
|
||||
companyCode?: string; // 프리뷰용 회사 코드
|
||||
mainTable?: string;
|
||||
mainTableLabel?: string;
|
||||
filterTables?: FilterTableInfo[];
|
||||
|
|
@ -199,6 +205,7 @@ export function ScreenSettingModal({
|
|||
screenId,
|
||||
screenName,
|
||||
groupId,
|
||||
companyCode,
|
||||
mainTable,
|
||||
mainTableLabel,
|
||||
filterTables = [],
|
||||
|
|
@ -209,6 +216,7 @@ export function ScreenSettingModal({
|
|||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
||||
const [layoutItems, setLayoutItems] = useState<LayoutItem[]>([]);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
|
|
@ -216,11 +224,18 @@ export function ScreenSettingModal({
|
|||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 데이터 흐름 로드
|
||||
const flowsResponse = await getDataFlows(screenId);
|
||||
// 1. 해당 화면에서 시작하는 데이터 흐름 로드
|
||||
const flowsResponse = await getDataFlows({ sourceScreenId: screenId });
|
||||
if (flowsResponse.success && flowsResponse.data) {
|
||||
setDataFlows(flowsResponse.data);
|
||||
}
|
||||
|
||||
// 2. 화면 레이아웃 요약 정보 로드 (컴포넌트 컬럼 정보 포함)
|
||||
const layoutResponse = await getMultipleScreenLayoutSummary([screenId]);
|
||||
if (layoutResponse.success && layoutResponse.data) {
|
||||
const screenLayout = layoutResponse.data[screenId];
|
||||
setLayoutItems(screenLayout?.layoutItems || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("데이터 로드 실패:", error);
|
||||
} finally {
|
||||
|
|
@ -299,6 +314,7 @@ export function ScreenSettingModal({
|
|||
fieldMappings={fieldMappings}
|
||||
componentCount={componentCount}
|
||||
dataFlows={dataFlows}
|
||||
layoutItems={layoutItems}
|
||||
loading={loading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
|
@ -309,6 +325,7 @@ export function ScreenSettingModal({
|
|||
screenId={screenId}
|
||||
mainTable={mainTable}
|
||||
fieldMappings={fieldMappings}
|
||||
layoutItems={layoutItems}
|
||||
loading={loading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
|
@ -327,7 +344,7 @@ export function ScreenSettingModal({
|
|||
|
||||
{/* 탭 4: 화면 프리뷰 */}
|
||||
<TabsContent value="preview" className="mt-0 min-h-0 flex-1 overflow-hidden p-4">
|
||||
<PreviewTab screenId={screenId} screenName={screenName} />
|
||||
<PreviewTab screenId={screenId} screenName={screenName} companyCode={companyCode} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
|
|
@ -335,6 +352,182 @@ export function ScreenSettingModal({
|
|||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 필터 테이블 아코디언 컴포넌트
|
||||
// ============================================================
|
||||
|
||||
interface FilterTableAccordionProps {
|
||||
filterTable: FilterTableInfo;
|
||||
mainTable?: string;
|
||||
}
|
||||
|
||||
function FilterTableAccordion({ filterTable: ft, mainTable }: FilterTableAccordionProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
const hasJoinRefs = ft.joinColumnRefs && ft.joinColumnRefs.length > 0;
|
||||
const hasFilterColumns = ft.filterColumns && ft.filterColumns.length > 0;
|
||||
|
||||
// 아코디언 열릴 때 테이블 컬럼 로드
|
||||
const handleToggle = async () => {
|
||||
const newIsOpen = !isOpen;
|
||||
setIsOpen(newIsOpen);
|
||||
|
||||
// 처음 열릴 때 컬럼 로드
|
||||
if (newIsOpen && columns.length === 0 && ft.tableName) {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await tableManagementApi.getColumnList(ft.tableName);
|
||||
if (result.success && result.data && result.data.columns) {
|
||||
setColumns(result.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 컬럼 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-purple-50/30 overflow-hidden">
|
||||
{/* 헤더 - 클릭하면 펼쳐짐 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-purple-50/50 transition-colors text-left"
|
||||
>
|
||||
{/* 펼침/접힘 아이콘 */}
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<Filter className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{ft.tableLabel || ft.tableName}</div>
|
||||
{ft.tableLabel && ft.tableName !== ft.tableLabel && (
|
||||
<div className="text-xs text-muted-foreground truncate">{ft.tableName}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 배지 - 보라색 */}
|
||||
<Badge variant="outline" className="bg-purple-100 text-purple-700 text-xs flex-shrink-0">
|
||||
필터
|
||||
</Badge>
|
||||
|
||||
{/* 요약 정보 */}
|
||||
<div className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{hasFilterColumns && `${ft.filterColumns!.length}개 필터`}
|
||||
{hasJoinRefs && hasFilterColumns && " / "}
|
||||
{hasJoinRefs && `${ft.joinColumnRefs!.length}개 조인`}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 펼쳐진 내용 */}
|
||||
{isOpen && (
|
||||
<div className="border-t border-purple-100 p-3 space-y-3 bg-white/50">
|
||||
{/* 필터 키 설명 */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-purple-700">{ft.tableLabel || ft.tableName}</span>의 데이터를 기준으로 필터링됩니다.
|
||||
</div>
|
||||
|
||||
{/* 테이블 컬럼 정보 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-gray-700">
|
||||
<Table2 className="h-3 w-3" />
|
||||
테이블 컬럼 ({loadingColumns ? "로딩중..." : `${columns.length}개`})
|
||||
</div>
|
||||
{loadingColumns ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : columns.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto">
|
||||
{columns.slice(0, 10).map((col, cIdx) => (
|
||||
<div
|
||||
key={cIdx}
|
||||
className="flex items-center gap-1 text-xs bg-gray-50 rounded px-2 py-1"
|
||||
>
|
||||
<span className="font-medium truncate">{col.displayName || col.columnName}</span>
|
||||
<span className="text-muted-foreground text-[10px] truncate">
|
||||
({col.dataType})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{columns.length > 10 && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
+{columns.length - 10}개 더
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground text-center py-1">
|
||||
컬럼 정보 없음
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 컬럼 매핑 */}
|
||||
{hasFilterColumns && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-purple-700">
|
||||
<Filter className="h-3 w-3" />
|
||||
필터 키 매핑
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ft.filterColumns!.map((col, cIdx) => (
|
||||
<div
|
||||
key={cIdx}
|
||||
className="flex items-center gap-2 text-xs bg-purple-50 rounded-md p-2"
|
||||
>
|
||||
<span className="rounded bg-white px-2 py-0.5 border border-purple-200 font-medium">
|
||||
{mainTable}.{col}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 text-purple-400" />
|
||||
<span className="rounded bg-purple-100 px-2 py-0.5 border border-purple-200">
|
||||
{ft.tableLabel || ft.tableName}.{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조인 관계 */}
|
||||
{hasJoinRefs && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-orange-700">
|
||||
<Link2 className="h-3 w-3" />
|
||||
조인 관계 ({ft.joinColumnRefs!.length}개)
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ft.joinColumnRefs!.map((join, jIdx) => (
|
||||
<div
|
||||
key={jIdx}
|
||||
className="flex items-center gap-2 text-xs bg-orange-50 rounded-md p-2"
|
||||
>
|
||||
<span className="rounded bg-white px-2 py-0.5 border border-orange-200 font-medium">
|
||||
{ft.tableLabel || ft.tableName}.{join.column}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 text-orange-400" />
|
||||
<span className="rounded bg-orange-100 px-2 py-0.5 border border-orange-200">
|
||||
{join.refTableLabel || join.refTable}.{join.refColumn}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 탭 1: 화면 개요
|
||||
// ============================================================
|
||||
|
|
@ -348,6 +541,7 @@ interface OverviewTabProps {
|
|||
fieldMappings: FieldMappingInfo[];
|
||||
componentCount: number;
|
||||
dataFlows: DataFlow[];
|
||||
layoutItems: LayoutItem[]; // 컴포넌트 컬럼 정보 추가
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -360,9 +554,10 @@ function OverviewTab({
|
|||
fieldMappings,
|
||||
componentCount,
|
||||
dataFlows,
|
||||
layoutItems,
|
||||
loading,
|
||||
}: OverviewTabProps) {
|
||||
// 통계 계산
|
||||
// 통계 계산 (layoutItems의 컬럼 수도 포함)
|
||||
const stats = useMemo(() => {
|
||||
const totalJoins = filterTables.reduce(
|
||||
(sum, ft) => sum + (ft.joinColumnRefs?.length || 0),
|
||||
|
|
@ -373,14 +568,23 @@ function OverviewTab({
|
|||
0
|
||||
);
|
||||
|
||||
// layoutItems에서 사용하는 컬럼 수 계산
|
||||
const layoutColumnsSet = new Set<string>();
|
||||
layoutItems.forEach((item) => {
|
||||
if (item.usedColumns) {
|
||||
item.usedColumns.forEach((col) => layoutColumnsSet.add(col));
|
||||
}
|
||||
});
|
||||
const layoutColumnCount = layoutColumnsSet.size;
|
||||
|
||||
return {
|
||||
tableCount: 1 + filterTables.length, // 메인 + 필터
|
||||
fieldCount: fieldMappings.length,
|
||||
fieldCount: layoutColumnCount > 0 ? layoutColumnCount : fieldMappings.length,
|
||||
joinCount: totalJoins,
|
||||
filterCount: totalFilters,
|
||||
flowCount: dataFlows.length,
|
||||
};
|
||||
}, [filterTables, fieldMappings, dataFlows]);
|
||||
}, [filterTables, fieldMappings, dataFlows, layoutItems]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -434,7 +638,7 @@ function OverviewTab({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 테이블 */}
|
||||
{/* 연결된 필터 테이블 (아코디언 형식) */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Link2 className="h-4 w-4 text-purple-500" />
|
||||
|
|
@ -443,63 +647,16 @@ function OverviewTab({
|
|||
{filterTables.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filterTables.map((ft, idx) => (
|
||||
<div
|
||||
<FilterTableAccordion
|
||||
key={`${ft.tableName}-${idx}`}
|
||||
className="rounded-lg border bg-purple-50/50 p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Table2 className="h-5 w-5 text-purple-500" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{ft.tableLabel || ft.tableName}</div>
|
||||
{ft.tableLabel && ft.tableName !== ft.tableLabel && (
|
||||
<div className="text-xs text-muted-foreground">{ft.tableName}</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-purple-100 text-purple-700">
|
||||
필터
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 조인 정보 */}
|
||||
{ft.joinColumnRefs && ft.joinColumnRefs.length > 0 && (
|
||||
<div className="mt-2 space-y-1 border-t pt-2">
|
||||
<div className="text-xs font-medium text-orange-700">조인 설정:</div>
|
||||
{ft.joinColumnRefs.map((join, jIdx) => (
|
||||
<div
|
||||
key={jIdx}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="rounded bg-orange-100 px-1.5 py-0.5">
|
||||
{join.column}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span className="rounded bg-green-100 px-1.5 py-0.5">
|
||||
{join.refTableLabel || join.refTable}.{join.refColumn}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 컬럼 */}
|
||||
{ft.filterColumns && ft.filterColumns.length > 0 && (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<div className="text-xs font-medium text-green-700">필터 컬럼:</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{ft.filterColumns.map((col, cIdx) => (
|
||||
<Badge key={cIdx} variant="outline" className="bg-green-50 text-xs">
|
||||
{col}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
filterTable={ft}
|
||||
mainTable={mainTable}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
필터 테이블이 없습니다.
|
||||
연결된 필터 테이블이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -549,6 +706,7 @@ interface FieldMappingTabProps {
|
|||
screenId: number;
|
||||
mainTable?: string;
|
||||
fieldMappings: FieldMappingInfo[];
|
||||
layoutItems: LayoutItem[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -556,9 +714,42 @@ function FieldMappingTab({
|
|||
screenId,
|
||||
mainTable,
|
||||
fieldMappings,
|
||||
layoutItems,
|
||||
loading,
|
||||
}: FieldMappingTabProps) {
|
||||
// 컴포넌트 타입별 그룹핑
|
||||
// 화면 컴포넌트에서 사용하는 컬럼 정보 추출
|
||||
const componentColumns = useMemo(() => {
|
||||
const result: Array<{
|
||||
componentKind: string;
|
||||
componentLabel?: string;
|
||||
columns: string[];
|
||||
joinColumns: string[];
|
||||
}> = [];
|
||||
|
||||
layoutItems.forEach((item) => {
|
||||
if (item.usedColumns && item.usedColumns.length > 0) {
|
||||
result.push({
|
||||
componentKind: item.componentKind,
|
||||
componentLabel: item.label,
|
||||
columns: item.usedColumns,
|
||||
joinColumns: item.joinColumns || [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [layoutItems]);
|
||||
|
||||
// 전체 컬럼 수 계산
|
||||
const totalColumns = useMemo(() => {
|
||||
const allColumns = new Set<string>();
|
||||
componentColumns.forEach((comp) => {
|
||||
comp.columns.forEach((col) => allColumns.add(col));
|
||||
});
|
||||
return allColumns.size;
|
||||
}, [componentColumns]);
|
||||
|
||||
// 컴포넌트 타입별 그룹핑 (기존 fieldMappings용)
|
||||
const groupedMappings = useMemo(() => {
|
||||
const grouped: Record<string, FieldMappingInfo[]> = {};
|
||||
|
||||
|
|
@ -584,83 +775,155 @@ function FieldMappingTab({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">필드-컬럼 매핑 현황</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
화면 필드가 어떤 테이블 컬럼과 연결되어 있는지 확인합니다.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
{/* 화면 컴포넌트별 컬럼 사용 현황 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">화면 컴포넌트별 컬럼 사용 현황</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
각 컴포넌트에서 사용하는 테이블 컬럼을 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
총 {totalColumns}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
총 {fieldMappings.length}개 필드
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{fieldMappings.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
<Columns3 className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-sm">설정된 필드 매핑이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-xs">#</TableHead>
|
||||
<TableHead className="text-xs">필드명</TableHead>
|
||||
<TableHead className="text-xs">테이블</TableHead>
|
||||
<TableHead className="text-xs">컬럼</TableHead>
|
||||
<TableHead className="text-xs">컴포넌트 타입</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fieldMappings.map((mapping, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">
|
||||
{mapping.targetField}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||||
{mapping.sourceTable || mainTable || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700">
|
||||
{mapping.sourceField}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{mapping.componentType || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 타입별 요약 */}
|
||||
{componentTypes.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">컴포넌트 타입별 분류</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{componentTypes.map((type) => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="outline"
|
||||
className="gap-1 bg-gray-50"
|
||||
{componentColumns.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
<Columns3 className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-sm">화면 컴포넌트에서 사용하는 컬럼 정보가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{componentColumns.map((comp, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-lg border bg-gray-50 p-3"
|
||||
>
|
||||
{type}
|
||||
<span className="rounded-full bg-gray-200 px-1.5 text-[10px]">
|
||||
{groupedMappings[type].length}
|
||||
</span>
|
||||
</Badge>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table2 className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">
|
||||
{comp.componentLabel || comp.componentKind}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{comp.componentKind}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{comp.columns.length}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{comp.columns.map((col, cIdx) => {
|
||||
const isJoinColumn = comp.joinColumns.includes(col);
|
||||
return (
|
||||
<Badge
|
||||
key={cIdx}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
isJoinColumn
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "bg-white"
|
||||
)}
|
||||
>
|
||||
{col}
|
||||
{isJoinColumn && (
|
||||
<Link2 className="ml-1 h-2.5 w-2.5" />
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 서브 테이블 연결 관계 (기존 fieldMappings) */}
|
||||
{fieldMappings.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">서브 테이블 연결 관계</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
메인 테이블과 서브 테이블 간의 필드 연결 관계입니다.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
총 {fieldMappings.length}개 연결
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-xs">#</TableHead>
|
||||
<TableHead className="text-xs">메인 테이블 컬럼</TableHead>
|
||||
<TableHead className="w-[60px] text-center text-xs"></TableHead>
|
||||
<TableHead className="text-xs">서브 테이블</TableHead>
|
||||
<TableHead className="text-xs">서브 테이블 컬럼</TableHead>
|
||||
<TableHead className="text-xs">연결 타입</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fieldMappings.map((mapping, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||||
{mainTable}.{mapping.targetField}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ArrowRight className="mx-auto h-3 w-3 text-gray-400" />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant="outline" className="bg-purple-50 text-purple-700">
|
||||
{mapping.sourceTable || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700">
|
||||
{mapping.sourceField}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{mapping.componentType || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 타입별 요약 */}
|
||||
{componentTypes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">연결 타입별 분류</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{componentTypes.map((type) => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="outline"
|
||||
className="gap-1 bg-gray-50"
|
||||
>
|
||||
{type}
|
||||
<span className="rounded-full bg-gray-200 px-1.5 text-[10px]">
|
||||
{groupedMappings[type].length}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -954,21 +1217,27 @@ function DataFlowTab({
|
|||
interface PreviewTabProps {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
function PreviewTab({ screenId, screenName }: PreviewTabProps) {
|
||||
function PreviewTab({ screenId, screenName, companyCode }: PreviewTabProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 화면 URL 생성
|
||||
// 화면 URL 생성 (preview=true로 사이드바 없이 화면만 표시, company_code 전달)
|
||||
const previewUrl = useMemo(() => {
|
||||
// 현재 호스트 기반으로 URL 생성
|
||||
const params = new URLSearchParams({ preview: "true" });
|
||||
// 프리뷰용 회사 코드 추가 (데이터 조회에 필요)
|
||||
if (companyCode) {
|
||||
params.set("company_code", companyCode);
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
const baseUrl = window.location.origin;
|
||||
return `${baseUrl}/screens/${screenId}`;
|
||||
return `${baseUrl}/screens/${screenId}?${params.toString()}`;
|
||||
}
|
||||
return `/screens/${screenId}`;
|
||||
}, [screenId]);
|
||||
return `/screens/${screenId}?${params.toString()}`;
|
||||
}, [screenId, companyCode]);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setLoading(false);
|
||||
|
|
|
|||
|
|
@ -254,10 +254,17 @@ export async function deleteFieldJoin(id: number): Promise<ApiResponse<void>> {
|
|||
// 데이터 흐름 (screen_data_flows) API
|
||||
// ============================================================
|
||||
|
||||
export async function getDataFlows(groupId?: number): Promise<ApiResponse<DataFlow[]>> {
|
||||
export async function getDataFlows(params?: { groupId?: number; sourceScreenId?: number }): Promise<ApiResponse<DataFlow[]>> {
|
||||
try {
|
||||
const queryParams = groupId ? `?group_id=${groupId}` : "";
|
||||
const response = await apiClient.get(`/screen-groups/data-flows${queryParams}`);
|
||||
const queryParts: string[] = [];
|
||||
if (params?.groupId) {
|
||||
queryParts.push(`group_id=${params.groupId}`);
|
||||
}
|
||||
if (params?.sourceScreenId) {
|
||||
queryParts.push(`source_screen_id=${params.sourceScreenId}`);
|
||||
}
|
||||
const queryString = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
|
||||
const response = await apiClient.get(`/screen-groups/data-flows${queryString}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
|
|
@ -403,9 +410,11 @@ export interface FieldMappingInfo {
|
|||
// 서브 테이블 정보 타입
|
||||
export interface SubTableInfo {
|
||||
tableName: string;
|
||||
tableLabel?: string; // 테이블 한글명
|
||||
componentType: string;
|
||||
relationType: 'lookup' | 'source' | 'join' | 'reference' | 'parentMapping' | 'rightPanelRelation';
|
||||
fieldMappings?: FieldMappingInfo[];
|
||||
filterColumns?: string[]; // 필터링에 사용되는 컬럼 목록
|
||||
// rightPanelRelation에서 추가 정보 (관계 유형 추론용)
|
||||
originalRelationType?: 'join' | 'detail'; // 원본 relation.type
|
||||
foreignKey?: string; // 디테일 테이블의 FK 컬럼
|
||||
|
|
|
|||
Loading…
Reference in New Issue