feat: 데이터 흐름 조회 기능 개선 및 프리뷰 모드 추가

- 데이터 흐름 조회 API에 source_screen_id 파라미터 추가하여 특정 화면에서 시작하는 데이터 흐름만 조회 가능
- 화면 관리 페이지에서 선택된 그룹에 company_code 필드 추가하여 회사 코드 정보 포함
- 프리뷰 모드에서 URL 쿼리로 company_code를 받아와 데이터 조회 시 우선 사용하도록 로직 개선
- 화면 관계 흐름 및 서브 테이블 정보에서 company_code를 활용하여 필터링 및 시각화 개선
This commit is contained in:
DDD1542 2026-01-09 18:26:37 +09:00
parent a6569909a2
commit 0773989c74
9 changed files with 482 additions and 148 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,6 +39,7 @@ export interface FieldMappingDisplay {
targetField: string; // 서브 테이블 컬럼 (예: user_id)
sourceDisplayName?: string; // 메인 테이블 한글 컬럼명 (예: 담당자)
targetDisplayName?: string; // 서브 테이블 한글 컬럼명 (예: 사용자ID)
sourceTable?: string; // 소스 테이블명 (필드 매핑에서 테이블 구분용)
}
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)

View File

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

View File

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

View File

@ -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 컬럼