diff --git a/PHASE_RESPONSIVE_LAYOUT.md b/PHASE_RESPONSIVE_LAYOUT.md new file mode 100644 index 00000000..3dde3d49 --- /dev/null +++ b/PHASE_RESPONSIVE_LAYOUT.md @@ -0,0 +1,998 @@ +# 반응형 레이아웃 시스템 구현 계획서 + +## 📋 프로젝트 개요 + +### 목표 + +화면 디자이너는 절대 위치 기반으로 유지하되, 실제 화면 표시는 반응형으로 동작하도록 전환 + +### 핵심 원칙 + +- ✅ 화면 디자이너의 절대 위치 기반 드래그앤드롭은 그대로 유지 +- ✅ 실제 화면 표시만 반응형으로 전환 +- ✅ 데이터 마이그레이션 불필요 (신규 화면부터 적용) +- ✅ 기존 화면은 불러올 때 스마트 기본값 자동 생성 + +--- + +## 🎯 Phase 1: 기본 반응형 시스템 구축 (2-3일) + +### 1.1 타입 정의 (2시간) + +#### 파일: `frontend/types/responsive.ts` + +```typescript +/** + * 브레이크포인트 타입 정의 + */ +export type Breakpoint = "desktop" | "tablet" | "mobile"; + +/** + * 브레이크포인트별 설정 + */ +export interface BreakpointConfig { + minWidth: number; // 최소 너비 (px) + maxWidth?: number; // 최대 너비 (px) + columns: number; // 그리드 컬럼 수 +} + +/** + * 기본 브레이크포인트 설정 + */ +export const BREAKPOINTS: Record = { + desktop: { + minWidth: 1200, + columns: 12, + }, + tablet: { + minWidth: 768, + maxWidth: 1199, + columns: 8, + }, + mobile: { + minWidth: 0, + maxWidth: 767, + columns: 4, + }, +}; + +/** + * 브레이크포인트별 반응형 설정 + */ +export interface ResponsiveBreakpointConfig { + gridColumns?: number; // 차지할 컬럼 수 (1-12) + order?: number; // 정렬 순서 + hide?: boolean; // 숨김 여부 +} + +/** + * 컴포넌트별 반응형 설정 + */ +export interface ResponsiveComponentConfig { + // 기본값 (디자이너에서 설정한 절대 위치) + designerPosition: { + x: number; + y: number; + width: number; + height: number; + }; + + // 반응형 설정 (선택적) + responsive?: { + desktop?: ResponsiveBreakpointConfig; + tablet?: ResponsiveBreakpointConfig; + mobile?: ResponsiveBreakpointConfig; + }; + + // 스마트 기본값 사용 여부 + useSmartDefaults?: boolean; +} +``` + +### 1.2 스마트 기본값 생성기 (3시간) + +#### 파일: `frontend/lib/utils/responsiveDefaults.ts` + +```typescript +import { ComponentData } from "@/types/screen-management"; +import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive"; + +/** + * 컴포넌트 크기에 따른 스마트 기본값 생성 + * + * 로직: + * - 작은 컴포넌트 (너비 25% 이하): 모바일에서도 같은 너비 유지 + * - 중간 컴포넌트 (너비 25-50%): 모바일에서 전체 너비로 확장 + * - 큰 컴포넌트 (너비 50% 이상): 모든 디바이스에서 전체 너비 + */ +export function generateSmartDefaults( + component: ComponentData, + screenWidth: number = 1920 +): ResponsiveComponentConfig["responsive"] { + const componentWidthPercent = (component.size.width / screenWidth) * 100; + + // 작은 컴포넌트 (25% 이하) + if (componentWidthPercent <= 25) { + return { + desktop: { + gridColumns: 3, // 12컬럼 중 3개 (25%) + order: 1, + hide: false, + }, + tablet: { + gridColumns: 2, // 8컬럼 중 2개 (25%) + order: 1, + hide: false, + }, + mobile: { + gridColumns: 1, // 4컬럼 중 1개 (25%) + order: 1, + hide: false, + }, + }; + } + // 중간 컴포넌트 (25-50%) + else if (componentWidthPercent <= 50) { + return { + desktop: { + gridColumns: 6, // 12컬럼 중 6개 (50%) + order: 1, + hide: false, + }, + tablet: { + gridColumns: 4, // 8컬럼 중 4개 (50%) + order: 1, + hide: false, + }, + mobile: { + gridColumns: 4, // 4컬럼 전체 (100%) + order: 1, + hide: false, + }, + }; + } + // 큰 컴포넌트 (50% 이상) + else { + return { + desktop: { + gridColumns: 12, // 전체 너비 + order: 1, + hide: false, + }, + tablet: { + gridColumns: 8, // 전체 너비 + order: 1, + hide: false, + }, + mobile: { + gridColumns: 4, // 전체 너비 + order: 1, + hide: false, + }, + }; + } +} + +/** + * 컴포넌트에 반응형 설정이 없을 경우 자동 생성 + */ +export function ensureResponsiveConfig( + component: ComponentData, + screenWidth?: number +): ComponentData { + if (component.responsiveConfig) { + return component; + } + + return { + ...component, + responsiveConfig: { + designerPosition: { + x: component.position.x, + y: component.position.y, + width: component.size.width, + height: component.size.height, + }, + useSmartDefaults: true, + responsive: generateSmartDefaults(component, screenWidth), + }, + }; +} +``` + +### 1.3 브레이크포인트 감지 훅 (1시간) + +#### 파일: `frontend/hooks/useBreakpoint.ts` + +```typescript +import { useState, useEffect } from "react"; +import { Breakpoint, BREAKPOINTS } from "@/types/responsive"; + +/** + * 현재 윈도우 크기에 따른 브레이크포인트 반환 + */ +export function useBreakpoint(): Breakpoint { + const [breakpoint, setBreakpoint] = useState("desktop"); + + useEffect(() => { + const updateBreakpoint = () => { + const width = window.innerWidth; + + if (width >= BREAKPOINTS.desktop.minWidth) { + setBreakpoint("desktop"); + } else if (width >= BREAKPOINTS.tablet.minWidth) { + setBreakpoint("tablet"); + } else { + setBreakpoint("mobile"); + } + }; + + // 초기 실행 + updateBreakpoint(); + + // 리사이즈 이벤트 리스너 등록 + window.addEventListener("resize", updateBreakpoint); + + return () => window.removeEventListener("resize", updateBreakpoint); + }, []); + + return breakpoint; +} + +/** + * 현재 브레이크포인트의 컬럼 수 반환 + */ +export function useGridColumns(): number { + const breakpoint = useBreakpoint(); + return BREAKPOINTS[breakpoint].columns; +} +``` + +### 1.4 반응형 레이아웃 엔진 (6시간) + +#### 파일: `frontend/components/screen/ResponsiveLayoutEngine.tsx` + +```typescript +import React, { useMemo } from "react"; +import { ComponentData } from "@/types/screen-management"; +import { Breakpoint, BREAKPOINTS } from "@/types/responsive"; +import { + generateSmartDefaults, + ensureResponsiveConfig, +} from "@/lib/utils/responsiveDefaults"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; + +interface ResponsiveLayoutEngineProps { + components: ComponentData[]; + breakpoint: Breakpoint; + containerWidth: number; + screenWidth?: number; +} + +/** + * 반응형 레이아웃 엔진 + * + * 절대 위치로 배치된 컴포넌트들을 반응형 그리드로 변환 + * + * 변환 로직: + * 1. Y 위치 기준으로 행(row)으로 그룹화 + * 2. 각 행 내에서 X 위치 기준으로 정렬 + * 3. 반응형 설정 적용 (order, gridColumns, hide) + * 4. CSS Grid로 렌더링 + */ +export const ResponsiveLayoutEngine: React.FC = ({ + components, + breakpoint, + containerWidth, + screenWidth = 1920, +}) => { + // 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화 + const rows = useMemo(() => { + const sortedComponents = [...components].sort( + (a, b) => a.position.y - b.position.y + ); + + const rows: ComponentData[][] = []; + let currentRow: ComponentData[] = []; + let currentRowY = 0; + const ROW_THRESHOLD = 50; // 같은 행으로 간주할 Y 오차 범위 (px) + + sortedComponents.forEach((comp) => { + if (currentRow.length === 0) { + currentRow.push(comp); + currentRowY = comp.position.y; + } else if (Math.abs(comp.position.y - currentRowY) < ROW_THRESHOLD) { + currentRow.push(comp); + } else { + rows.push(currentRow); + currentRow = [comp]; + currentRowY = comp.position.y; + } + }); + + if (currentRow.length > 0) { + rows.push(currentRow); + } + + return rows; + }, [components]); + + // 2단계: 각 행 내에서 X 위치 기준으로 정렬 + const sortedRows = useMemo(() => { + return rows.map((row) => + [...row].sort((a, b) => a.position.x - b.position.x) + ); + }, [rows]); + + // 3단계: 반응형 설정 적용 + const responsiveComponents = useMemo(() => { + return sortedRows.flatMap((row) => + row.map((comp) => { + // 반응형 설정이 없으면 자동 생성 + const compWithConfig = ensureResponsiveConfig(comp, screenWidth); + + // 현재 브레이크포인트의 설정 가져오기 + const config = compWithConfig.responsiveConfig!.useSmartDefaults + ? generateSmartDefaults(comp, screenWidth)[breakpoint] + : compWithConfig.responsiveConfig!.responsive?.[breakpoint]; + + return { + ...compWithConfig, + responsiveDisplay: + config || generateSmartDefaults(comp, screenWidth)[breakpoint], + }; + }) + ); + }, [sortedRows, breakpoint, screenWidth]); + + // 4단계: 필터링 및 정렬 + const visibleComponents = useMemo(() => { + return responsiveComponents + .filter((comp) => !comp.responsiveDisplay?.hide) + .sort( + (a, b) => + (a.responsiveDisplay?.order || 0) - (b.responsiveDisplay?.order || 0) + ); + }, [responsiveComponents]); + + const gridColumns = BREAKPOINTS[breakpoint].columns; + + return ( +
+ {visibleComponents.map((comp) => ( +
+ +
+ ))} +
+ ); +}; +``` + +### 1.5 화면 표시 페이지 수정 (4시간) + +#### 파일: `frontend/app/(main)/screens/[screenId]/page.tsx` + +```typescript +// 기존 import 유지 +import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine"; +import { useBreakpoint } from "@/hooks/useBreakpoint"; + +export default function ScreenViewPage({ + params, +}: { + params: { screenId: string }; +}) { + const [layout, setLayout] = useState(null); + const breakpoint = useBreakpoint(); + + // 반응형 모드 토글 (사용자 설정 또는 화면 설정에 따라) + const [useResponsive, setUseResponsive] = useState(true); + + // 기존 로직 유지... + + if (!layout) { + return
로딩 중...
; + } + + const screenWidth = layout.screenResolution?.width || 1920; + const screenHeight = layout.screenResolution?.height || 1080; + + return ( +
+ {useResponsive ? ( + // 반응형 모드 + + ) : ( + // 기존 스케일 모드 (하위 호환성) +
+
+
+ {layout.components?.map((component) => ( +
+ +
+ ))} +
+
+
+ )} +
+ ); +} +``` + +--- + +## 🎨 Phase 2: 디자이너 통합 (1-2일) + +### 2.1 반응형 설정 패널 (5시간) + +#### 파일: `frontend/components/screen/panels/ResponsiveConfigPanel.tsx` + +```typescript +import React, { useState } from "react"; +import { ComponentData } from "@/types/screen-management"; +import { + Breakpoint, + BREAKPOINTS, + ResponsiveComponentConfig, +} from "@/types/responsive"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Checkbox } from "@/components/ui/checkbox"; + +interface ResponsiveConfigPanelProps { + component: ComponentData; + onUpdate: (config: ResponsiveComponentConfig) => void; +} + +export const ResponsiveConfigPanel: React.FC = ({ + component, + onUpdate, +}) => { + const [activeTab, setActiveTab] = useState("desktop"); + + const config = component.responsiveConfig || { + designerPosition: { + x: component.position.x, + y: component.position.y, + width: component.size.width, + height: component.size.height, + }, + useSmartDefaults: true, + }; + + return ( + + + 반응형 설정 + + + {/* 스마트 기본값 토글 */} +
+ { + onUpdate({ + ...config, + useSmartDefaults: checked as boolean, + }); + }} + /> + +
+ + {/* 수동 설정 */} + {!config.useSmartDefaults && ( + setActiveTab(v as Breakpoint)} + > + + 데스크톱 + 태블릿 + 모바일 + + + + {/* 그리드 컬럼 수 */} +
+ + +
+ + {/* 표시 순서 */} +
+ + { + onUpdate({ + ...config, + responsive: { + ...config.responsive, + [activeTab]: { + ...config.responsive?.[activeTab], + order: parseInt(e.target.value), + }, + }, + }); + }} + /> +
+ + {/* 숨김 */} +
+ { + onUpdate({ + ...config, + responsive: { + ...config.responsive, + [activeTab]: { + ...config.responsive?.[activeTab], + hide: checked as boolean, + }, + }, + }); + }} + /> + +
+
+
+ )} +
+
+ ); +}; +``` + +### 2.2 속성 패널 통합 (1시간) + +#### 파일: `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정 + +```typescript +// 기존 import에 추가 +import { ResponsiveConfigPanel } from './ResponsiveConfigPanel'; + +// 컴포넌트 내부에 추가 +return ( +
+ {/* 기존 패널들 */} + + + + {/* 반응형 설정 패널 추가 */} + { + onUpdateComponent({ + ...selectedComponent, + responsiveConfig: config + }); + }} + /> + + {/* 기존 세부 설정 패널 */} + +
+); +``` + +### 2.3 미리보기 모드 (3시간) + +#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정 + +```typescript +// 추가 import +import { Breakpoint } from '@/types/responsive'; +import { ResponsiveLayoutEngine } from './ResponsiveLayoutEngine'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { Button } from '@/components/ui/button'; + +export const ScreenDesigner: React.FC = () => { + // 미리보기 모드: 'design' | 'desktop' | 'tablet' | 'mobile' + const [previewMode, setPreviewMode] = useState<'design' | Breakpoint>('design'); + const currentBreakpoint = useBreakpoint(); + + // ... 기존 로직 ... + + return ( +
+ {/* 상단 툴바 */} +
+ + + + +
+ + {/* 캔버스 영역 */} +
+ {previewMode === 'design' ? ( + // 기존 절대 위치 기반 디자이너 + + ) : ( + // 반응형 미리보기 +
+ +
+ )} +
+
+ ); +}; +``` + +--- + +## 💾 Phase 3: 저장/불러오기 (1일) + +### 3.1 타입 업데이트 (2시간) + +#### 파일: `frontend/types/screen-management.ts` 수정 + +```typescript +import { ResponsiveComponentConfig } from "./responsive"; + +export interface ComponentData { + // ... 기존 필드들 ... + + // 반응형 설정 추가 + responsiveConfig?: ResponsiveComponentConfig; +} +``` + +### 3.2 저장 로직 (2시간) + +#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정 + +```typescript +// 저장 함수 수정 +const handleSave = async () => { + try { + const layoutData: LayoutData = { + screenResolution: { + width: 1920, + height: 1080, + }, + components: components.map((comp) => ({ + ...comp, + // 반응형 설정이 없으면 자동 생성 + responsiveConfig: comp.responsiveConfig || { + designerPosition: { + x: comp.position.x, + y: comp.position.y, + width: comp.size.width, + height: comp.size.height, + }, + useSmartDefaults: true, + }, + })), + }; + + await screenApi.updateLayout(selectedScreen.id, layoutData); + // ... 기존 로직 ... + } catch (error) { + console.error("저장 실패:", error); + } +}; +``` + +### 3.3 불러오기 로직 (2시간) + +#### 파일: `frontend/components/screen/ScreenDesigner.tsx` 수정 + +```typescript +import { ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults"; + +// 화면 불러오기 +useEffect(() => { + const loadScreen = async () => { + if (!selectedScreenId) return; + + const screen = await screenApi.getScreenById(selectedScreenId); + const layout = await screenApi.getLayout(selectedScreenId); + + // 반응형 설정이 없는 컴포넌트에 자동 생성 + const componentsWithResponsive = layout.components.map((comp) => + ensureResponsiveConfig(comp, layout.screenResolution?.width) + ); + + setSelectedScreen(screen); + setComponents(componentsWithResponsive); + }; + + loadScreen(); +}, [selectedScreenId]); +``` + +--- + +## 🧪 Phase 4: 테스트 및 최적화 (1일) + +### 4.1 기능 테스트 체크리스트 (3시간) + +- [ ] 브레이크포인트 전환 테스트 + - [ ] 윈도우 크기 변경 시 자동 전환 + - [ ] desktop → tablet → mobile 순차 테스트 +- [ ] 스마트 기본값 생성 테스트 + - [ ] 작은 컴포넌트 (25% 이하) + - [ ] 중간 컴포넌트 (25-50%) + - [ ] 큰 컴포넌트 (50% 이상) +- [ ] 수동 설정 적용 테스트 + - [ ] 그리드 컬럼 변경 + - [ ] 표시 순서 변경 + - [ ] 디바이스별 숨김 +- [ ] 미리보기 모드 테스트 + - [ ] 디자인 모드 ↔ 미리보기 모드 전환 + - [ ] 각 브레이크포인트 미리보기 +- [ ] 저장/불러오기 테스트 + - [ ] 반응형 설정 저장 + - [ ] 기존 화면 불러오기 시 자동 변환 + +### 4.2 성능 최적화 (3시간) + +#### 레이아웃 계산 메모이제이션 + +```typescript +// ResponsiveLayoutEngine.tsx +const memoizedLayout = useMemo(() => { + // 레이아웃 계산 로직 +}, [components, breakpoint, screenWidth]); +``` + +#### ResizeObserver 최적화 + +```typescript +// useBreakpoint.ts +// debounce 적용 +const debouncedResize = debounce(updateBreakpoint, 150); +window.addEventListener("resize", debouncedResize); +``` + +#### 불필요한 리렌더링 방지 + +```typescript +// React.memo 적용 +export const ResponsiveLayoutEngine = React.memo(({...}) => { + // ... +}); +``` + +### 4.3 UI/UX 개선 (2시간) + +- [ ] 반응형 설정 패널 툴팁 추가 +- [ ] 미리보기 모드 전환 애니메이션 +- [ ] 로딩 상태 표시 +- [ ] 에러 처리 및 사용자 피드백 + +--- + +## 📅 최종 타임라인 + +| Phase | 작업 내용 | 소요 시간 | 누적 시간 | +| ------- | --------------------- | --------- | ------------ | +| Phase 1 | 타입 정의 및 유틸리티 | 6시간 | 6시간 | +| Phase 1 | 반응형 레이아웃 엔진 | 6시간 | 12시간 | +| Phase 1 | 화면 표시 페이지 수정 | 4시간 | 16시간 (2일) | +| Phase 2 | 반응형 설정 패널 | 5시간 | 21시간 | +| Phase 2 | 디자이너 통합 | 4시간 | 25시간 (3일) | +| Phase 3 | 저장/불러오기 | 6시간 | 31시간 (4일) | +| Phase 4 | 테스트 및 최적화 | 8시간 | 39시간 (5일) | + +**총 예상 시간: 39시간 (약 5일)** + +--- + +## 🎯 구현 우선순위 + +### 1단계: 핵심 기능 (필수) + +1. ✅ 타입 정의 +2. ✅ 스마트 기본값 생성기 +3. ✅ 브레이크포인트 훅 +4. ✅ 반응형 레이아웃 엔진 +5. ✅ 화면 표시 페이지 수정 + +### 2단계: 디자이너 UI (중요) + +6. ✅ 반응형 설정 패널 +7. ✅ 속성 패널 통합 +8. ✅ 미리보기 모드 + +### 3단계: 데이터 처리 (중요) + +9. ✅ 타입 업데이트 +10. ✅ 저장/불러오기 로직 + +### 4단계: 완성도 (선택) + +11. 테스트 +12. 최적화 +13. UI/UX 개선 + +--- + +## ✅ 완료 체크리스트 + +### Phase 1: 기본 시스템 + +- [ ] `frontend/types/responsive.ts` 생성 +- [ ] `frontend/lib/utils/responsiveDefaults.ts` 생성 +- [ ] `frontend/hooks/useBreakpoint.ts` 생성 +- [ ] `frontend/components/screen/ResponsiveLayoutEngine.tsx` 생성 +- [ ] `frontend/app/(main)/screens/[screenId]/page.tsx` 수정 + +### Phase 2: 디자이너 통합 + +- [ ] `frontend/components/screen/panels/ResponsiveConfigPanel.tsx` 생성 +- [ ] `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` 수정 +- [ ] `frontend/components/screen/ScreenDesigner.tsx` 수정 + +### Phase 3: 데이터 처리 + +- [ ] `frontend/types/screen-management.ts` 수정 +- [ ] 저장 로직 수정 +- [ ] 불러오기 로직 수정 + +### Phase 4: 테스트 + +- [ ] 기능 테스트 완료 +- [ ] 성능 최적화 완료 +- [ ] UI/UX 개선 완료 + +--- + +## 🚀 시작 준비 완료 + +이제 Phase 1부터 순차적으로 구현을 시작합니다. diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 0f6f07cc..7d710110 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -515,6 +515,7 @@ export class DashboardController { }); // 외부 API 호출 + // @ts-ignore - node-fetch dynamic import const fetch = (await import("node-fetch")).default; const response = await fetch(urlObj.toString(), { method: method.toUpperCase(), diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 7130777c..f7900b94 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -104,6 +104,30 @@ export const updateScreen = async ( } }; +// 화면 정보 수정 (메타데이터만) +export const updateScreenInfo = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + const { screenName, description, isActive } = req.body; + + await screenManagementService.updateScreenInfo( + parseInt(id), + { screenName, description, isActive }, + companyCode + ); + res.json({ success: true, message: "화면 정보가 수정되었습니다." }); + } catch (error) { + console.error("화면 정보 수정 실패:", error); + res + .status(500) + .json({ success: false, message: "화면 정보 수정에 실패했습니다." }); + } +}; + // 화면 의존성 체크 export const checkScreenDependencies = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 4ad42561..fb0f1518 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -5,6 +5,92 @@ import { AuthenticatedRequest } from "../types/auth"; const router = express.Router(); +/** + * 조인 데이터 조회 API (다른 라우트보다 먼저 정의) + * GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=... + */ +router.get( + "/join", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { leftTable, rightTable, leftColumn, rightColumn, leftValue } = + req.query; + + // 입력값 검증 + if (!leftTable || !rightTable || !leftColumn || !rightColumn) { + return res.status(400).json({ + success: false, + message: + "필수 파라미터가 누락되었습니다 (leftTable, rightTable, leftColumn, rightColumn).", + error: "MISSING_PARAMETERS", + }); + } + + // SQL 인젝션 방지를 위한 검증 + const tables = [leftTable as string, rightTable as string]; + const columns = [leftColumn as string, rightColumn as string]; + + for (const table of tables) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) { + return res.status(400).json({ + success: false, + message: `유효하지 않은 테이블명입니다: ${table}`, + error: "INVALID_TABLE_NAME", + }); + } + } + + for (const column of columns) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(column)) { + return res.status(400).json({ + success: false, + message: `유효하지 않은 컬럼명입니다: ${column}`, + error: "INVALID_COLUMN_NAME", + }); + } + } + + console.log(`🔗 조인 데이터 조회:`, { + leftTable, + rightTable, + leftColumn, + rightColumn, + leftValue, + }); + + // 조인 데이터 조회 + const result = await dataService.getJoinedData( + leftTable as string, + rightTable as string, + leftColumn as string, + rightColumn as string, + leftValue as string + ); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log( + `✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목` + ); + + return res.json({ + success: true, + data: result.data, + }); + } catch (error) { + console.error("조인 데이터 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "조인 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + /** * 동적 테이블 데이터 조회 API * GET /api/data/{tableName} @@ -15,7 +101,18 @@ router.get( async (req: AuthenticatedRequest, res) => { try { const { tableName } = req.params; - const { limit = "10", offset = "0", orderBy, ...filters } = req.query; + const { + limit, + offset, + page, + size, + orderBy, + searchTerm, + sortBy, + sortOrder, + userLang, + ...filters + } = req.query; // 입력값 검증 if (!tableName || typeof tableName !== "string") { @@ -35,21 +132,43 @@ router.get( }); } + // page/size 또는 limit/offset 방식 지원 + let finalLimit = 100; + let finalOffset = 0; + + if (page && size) { + // page/size 방식 + const pageNum = parseInt(page as string) || 1; + const sizeNum = parseInt(size as string) || 100; + finalLimit = sizeNum; + finalOffset = (pageNum - 1) * sizeNum; + } else if (limit || offset) { + // limit/offset 방식 + finalLimit = parseInt(limit as string) || 10; + finalOffset = parseInt(offset as string) || 0; + } + console.log(`📊 데이터 조회 요청: ${tableName}`, { - limit: parseInt(limit as string), - offset: parseInt(offset as string), - orderBy: orderBy as string, + limit: finalLimit, + offset: finalOffset, + orderBy: orderBy || sortBy, + searchTerm, filters, user: req.user?.userId, }); + // filters에서 searchTerm과 sortOrder 제거 (이미 별도로 처리됨) + const cleanFilters = { ...filters }; + delete cleanFilters.searchTerm; + delete cleanFilters.sortOrder; + // 데이터 조회 const result = await dataService.getTableData({ tableName, - limit: parseInt(limit as string), - offset: parseInt(offset as string), - orderBy: orderBy as string, - filters: filters as Record, + limit: finalLimit, + offset: finalOffset, + orderBy: (orderBy || sortBy) as string, + filters: cleanFilters as Record, userCompany: req.user?.companyCode, }); @@ -61,7 +180,21 @@ router.get( `✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목` ); - return res.json(result.data); + // 페이징 정보 포함하여 반환 + const total = result.data?.length || 0; + const responsePage = + finalLimit > 0 ? Math.floor(finalOffset / finalLimit) + 1 : 1; + const responseSize = finalLimit; + const totalPages = responseSize > 0 ? Math.ceil(total / responseSize) : 1; + + return res.json({ + success: true, + data: result.data, + total, + page: responsePage, + size: responseSize, + totalPages, + }); } catch (error) { console.error("데이터 조회 오류:", error); return res.status(500).json({ @@ -127,4 +260,231 @@ router.get( } ); +/** + * 레코드 상세 조회 API + * GET /api/data/{tableName}/{id} + */ +router.get( + "/:tableName/:id", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, id } = req.params; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`); + + // 레코드 상세 조회 + const result = await dataService.getRecordDetail(tableName, id); + + if (!result.success) { + return res.status(400).json(result); + } + + if (!result.data) { + return res.status(404).json({ + success: false, + message: "레코드를 찾을 수 없습니다.", + }); + } + + console.log(`✅ 레코드 조회 성공: ${tableName}/${id}`); + + return res.json({ + success: true, + data: result.data, + }); + } catch (error) { + console.error("레코드 상세 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "레코드 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +/** + * 레코드 생성 API + * POST /api/data/{tableName} + */ +router.post( + "/:tableName", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName } = req.params; + const data = req.body; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`➕ 레코드 생성: ${tableName}`, data); + + // 레코드 생성 + const result = await dataService.createRecord(tableName, data); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 레코드 생성 성공: ${tableName}`); + + return res.status(201).json({ + success: true, + data: result.data, + message: "레코드가 생성되었습니다.", + }); + } catch (error) { + console.error("레코드 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "레코드 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +/** + * 레코드 수정 API + * PUT /api/data/{tableName}/{id} + */ +router.put( + "/:tableName/:id", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, id } = req.params; + const data = req.body; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data); + + // 레코드 수정 + const result = await dataService.updateRecord(tableName, id, data); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 레코드 수정 성공: ${tableName}/${id}`); + + return res.json({ + success: true, + data: result.data, + message: "레코드가 수정되었습니다.", + }); + } catch (error) { + console.error("레코드 수정 오류:", error); + return res.status(500).json({ + success: false, + message: "레코드 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +/** + * 레코드 삭제 API + * DELETE /api/data/{tableName}/{id} + */ +router.delete( + "/:tableName/:id", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, id } = req.params; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`🗑️ 레코드 삭제: ${tableName}/${id}`); + + // 레코드 삭제 + const result = await dataService.deleteRecord(tableName, id); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 레코드 삭제 성공: ${tableName}/${id}`); + + return res.json({ + success: true, + message: "레코드가 삭제되었습니다.", + }); + } catch (error) { + console.error("레코드 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "레코드 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + export default router; diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index bc15c279..3fed9129 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -5,6 +5,7 @@ import { getScreen, createScreen, updateScreen, + updateScreenInfo, deleteScreen, checkScreenDependencies, restoreScreen, @@ -34,6 +35,7 @@ router.get("/screens", getScreens); router.get("/screens/:id", getScreen); router.post("/screens", createScreen); router.put("/screens/:id", updateScreen); +router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 router.post("/screens/:id/copy", copyScreen); diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 2608e8c9..661ffae1 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -313,6 +313,283 @@ class DataService { return null; } } + + /** + * 레코드 상세 조회 + */ + async getRecordDetail( + tableName: string, + id: string | number + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + // Primary Key 컬럼 찾기 + const pkResult = await query<{ attname: string }>( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [tableName] + ); + + let pkColumn = "id"; // 기본값 + if (pkResult.length > 0) { + pkColumn = pkResult[0].attname; + } + + const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`; + const result = await query(queryText, [id]); + + if (result.length === 0) { + return { + success: false, + message: "레코드를 찾을 수 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + + return { + success: true, + data: result[0], + }; + } catch (error) { + console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error); + return { + success: false, + message: "레코드 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 조인된 데이터 조회 + */ + async getJoinedData( + leftTable: string, + rightTable: string, + leftColumn: string, + rightColumn: string, + leftValue?: string | number + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(leftTable)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${leftTable}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + if (!ALLOWED_TABLES.includes(rightTable)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${rightTable}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + let queryText = ` + SELECT r.* + FROM "${rightTable}" r + INNER JOIN "${leftTable}" l + ON l."${leftColumn}" = r."${rightColumn}" + `; + + const values: any[] = []; + if (leftValue !== undefined && leftValue !== null) { + queryText += ` WHERE l."${leftColumn}" = $1`; + values.push(leftValue); + } + + const result = await query(queryText, values); + + return { + success: true, + data: result, + }; + } catch (error) { + console.error( + `조인 데이터 조회 오류 (${leftTable} → ${rightTable}):`, + error + ); + return { + success: false, + message: "조인 데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 레코드 생성 + */ + async createRecord( + tableName: string, + data: Record + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); + const columnNames = columns.map((col) => `"${col}"`).join(", "); + + const queryText = ` + INSERT INTO "${tableName}" (${columnNames}) + VALUES (${placeholders}) + RETURNING * + `; + + const result = await query(queryText, values); + + return { + success: true, + data: result[0], + }; + } catch (error) { + console.error(`레코드 생성 오류 (${tableName}):`, error); + return { + success: false, + message: "레코드 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 레코드 수정 + */ + async updateRecord( + tableName: string, + id: string | number, + data: Record + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + // Primary Key 컬럼 찾기 + const pkResult = await query<{ attname: string }>( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [tableName] + ); + + let pkColumn = "id"; + if (pkResult.length > 0) { + pkColumn = pkResult[0].attname; + } + + const columns = Object.keys(data); + const values = Object.values(data); + const setClause = columns + .map((col, index) => `"${col}" = $${index + 1}`) + .join(", "); + + const queryText = ` + UPDATE "${tableName}" + SET ${setClause} + WHERE "${pkColumn}" = $${values.length + 1} + RETURNING * + `; + + values.push(id); + const result = await query(queryText, values); + + if (result.length === 0) { + return { + success: false, + message: "레코드를 찾을 수 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + + return { + success: true, + data: result[0], + }; + } catch (error) { + console.error(`레코드 수정 오류 (${tableName}/${id}):`, error); + return { + success: false, + message: "레코드 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 레코드 삭제 + */ + async deleteRecord( + tableName: string, + id: string | number + ): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + // Primary Key 컬럼 찾기 + const pkResult = await query<{ attname: string }>( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [tableName] + ); + + let pkColumn = "id"; + if (pkResult.length > 0) { + pkColumn = pkResult[0].attname; + } + + const queryText = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; + await query(queryText, [id]); + + return { + success: true, + }; + } catch (error) { + console.error(`레코드 삭제 오류 (${tableName}/${id}):`, error); + return { + success: false, + message: "레코드 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } } export const dataService = new DataService(); diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index d228f80d..331f980e 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -295,6 +295,54 @@ export class DynamicFormService { } }); + // 📝 RepeaterInput 데이터 처리 (JSON 배열을 개별 레코드로 분해) + const repeaterData: Array<{ + data: Record[]; + targetTable?: string; + componentId: string; + }> = []; + Object.keys(dataToInsert).forEach((key) => { + const value = dataToInsert[key]; + + // RepeaterInput 데이터인지 확인 (JSON 배열 문자열) + if ( + typeof value === "string" && + value.trim().startsWith("[") && + value.trim().endsWith("]") + ) { + try { + const parsedArray = JSON.parse(value); + if (Array.isArray(parsedArray) && parsedArray.length > 0) { + console.log( + `🔄 RepeaterInput 데이터 감지: ${key}, ${parsedArray.length}개 항목` + ); + + // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) + // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 + let targetTable: string | undefined; + let actualData = parsedArray; + + // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) + if (parsedArray[0] && parsedArray[0]._targetTable) { + targetTable = parsedArray[0]._targetTable; + actualData = parsedArray.map( + ({ _targetTable, ...item }) => item + ); + } + + repeaterData.push({ + data: actualData, + targetTable, + componentId: key, + }); + delete dataToInsert[key]; // 원본 배열 데이터는 제거 + } + } catch (parseError) { + console.log(`⚠️ JSON 파싱 실패: ${key}`); + } + } + }); + // 존재하지 않는 컬럼 제거 Object.keys(dataToInsert).forEach((key) => { if (!tableColumns.includes(key)) { @@ -305,6 +353,9 @@ export class DynamicFormService { } }); + // RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리 + // (각 Repeater가 다른 테이블에 저장될 수 있으므로) + console.log("🎯 실제 테이블에 삽입할 데이터:", { tableName, dataToInsert, @@ -388,6 +439,111 @@ export class DynamicFormService { // 결과를 표준 형식으로 변환 const insertedRecord = Array.isArray(result) ? result[0] : result; + // 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장) + if (repeaterData.length > 0) { + console.log( + `🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length}개 Repeater` + ); + + for (const repeater of repeaterData) { + const targetTableName = repeater.targetTable || tableName; + console.log( + `📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장` + ); + + // 대상 테이블의 컬럼 및 기본키 정보 조회 + const targetTableColumns = + await this.getTableColumns(targetTableName); + const targetPrimaryKeys = await this.getPrimaryKeys(targetTableName); + + // 컬럼명만 추출 + const targetColumnNames = targetTableColumns.map( + (col) => col.columnName + ); + + // 각 항목을 저장 + for (let i = 0; i < repeater.data.length; i++) { + const item = repeater.data[i]; + const itemData: Record = { + ...item, + created_by, + updated_by, + regdate: new Date(), + }; + + // 대상 테이블에 존재하는 컬럼만 필터링 + Object.keys(itemData).forEach((key) => { + if (!targetColumnNames.includes(key)) { + delete itemData[key]; + } + }); + + // 타입 변환 적용 + Object.keys(itemData).forEach((columnName) => { + const column = targetTableColumns.find( + (col) => col.columnName === columnName + ); + if (column) { + itemData[columnName] = this.convertValueForPostgreSQL( + itemData[columnName], + column.dataType + ); + } + }); + + // UPSERT 쿼리 생성 + const itemColumns = Object.keys(itemData); + const itemValues: any[] = Object.values(itemData); + const itemPlaceholders = itemValues + .map((_, index) => `$${index + 1}`) + .join(", "); + + let itemUpsertQuery: string; + if (targetPrimaryKeys.length > 0) { + const conflictColumns = targetPrimaryKeys.join(", "); + const updateSet = itemColumns + .filter((col) => !targetPrimaryKeys.includes(col)) + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + if (updateSet) { + itemUpsertQuery = ` + INSERT INTO ${targetTableName} (${itemColumns.join(", ")}) + VALUES (${itemPlaceholders}) + ON CONFLICT (${conflictColumns}) + DO UPDATE SET ${updateSet} + RETURNING * + `; + } else { + itemUpsertQuery = ` + INSERT INTO ${targetTableName} (${itemColumns.join(", ")}) + VALUES (${itemPlaceholders}) + ON CONFLICT (${conflictColumns}) + DO NOTHING + RETURNING * + `; + } + } else { + itemUpsertQuery = ` + INSERT INTO ${targetTableName} (${itemColumns.join(", ")}) + VALUES (${itemPlaceholders}) + RETURNING * + `; + } + + console.log( + ` 📝 항목 ${i + 1}/${repeater.data.length} 저장:`, + itemData + ); + await query(itemUpsertQuery, itemValues); + } + + console.log(` ✅ Repeater "${repeater.componentId}" 저장 완료`); + } + + console.log(`✅ 모든 RepeaterInput 데이터 저장 완료`); + } + // 🔥 조건부 연결 실행 (INSERT 트리거) try { if (company_code) { @@ -1114,6 +1270,31 @@ export class DynamicFormService { } } + /** + * 테이블의 기본키 컬럼명 목록 조회 + */ + async getPrimaryKeys(tableName: string): Promise { + try { + console.log("🔑 서비스: 테이블 기본키 조회 시작:", { tableName }); + + const result = await query<{ column_name: string }>( + `SELECT a.attname AS column_name + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [tableName] + ); + + const primaryKeys = result.map((row) => row.column_name); + console.log("✅ 서비스: 테이블 기본키 조회 성공:", primaryKeys); + + return primaryKeys; + } catch (error) { + console.error("❌ 서비스: 테이블 기본키 조회 실패:", error); + throw new Error(`테이블 기본키 조회 실패: ${error}`); + } + } + /** * 제어관리 실행 (화면에 설정된 경우) */ diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 095ac938..df2823c8 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -219,7 +219,11 @@ export class EntityJoinService { ]; const separator = config.separator || " - "; - if (displayColumns.length === 1) { + if (displayColumns.length === 0 || !displayColumns[0]) { + // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 + // 조인 테이블의 referenceColumn을 기본값으로 사용 + return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`; + } else if (displayColumns.length === 1) { // 단일 컬럼인 경우 const col = displayColumns[0]; const isJoinTableColumn = [ diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6da8d16a..a984fa85 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -300,6 +300,51 @@ export class ScreenManagementService { return this.mapToScreenDefinition(screen); } + /** + * 화면 정보 수정 (메타데이터만) - 편집 기능용 + */ + async updateScreenInfo( + screenId: number, + updateData: { screenName: string; description?: string; isActive: string }, + userCompanyCode: string + ): Promise { + // 권한 확인 + const existingResult = await query<{ company_code: string | null }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (existingResult.length === 0) { + throw new Error("화면을 찾을 수 없습니다."); + } + + const existingScreen = existingResult[0]; + + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + throw new Error("이 화면을 수정할 권한이 없습니다."); + } + + // 화면 정보 업데이트 + await query( + `UPDATE screen_definitions + SET screen_name = $1, + description = $2, + is_active = $3, + updated_date = $4 + WHERE screen_id = $5`, + [ + updateData.screenName, + updateData.description || null, + updateData.isActive, + new Date(), + screenId, + ] + ); + } + /** * 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인 */ diff --git a/backend-node/src/types/unified-web-types.ts b/backend-node/src/types/unified-web-types.ts index 52f953ac..9ac51e57 100644 --- a/backend-node/src/types/unified-web-types.ts +++ b/backend-node/src/types/unified-web-types.ts @@ -112,9 +112,6 @@ export const DB_TYPE_TO_WEB_TYPE: Record = { json: "textarea", jsonb: "textarea", - // 배열 타입 (텍스트로 처리) - ARRAY: "textarea", - // UUID 타입 uuid: "text", }; diff --git a/frontend/app/(main)/admin/dataflow/node-editor/page.tsx b/frontend/app/(main)/admin/dataflow/node-editor/page.tsx index a8ba0d66..9e1cfab6 100644 --- a/frontend/app/(main)/admin/dataflow/node-editor/page.tsx +++ b/frontend/app/(main)/admin/dataflow/node-editor/page.tsx @@ -1,26 +1,24 @@ "use client"; /** - * 제어 시스템 페이지 + * 제어 시스템 페이지 (리다이렉트) + * 이 페이지는 /admin/dataflow로 리다이렉트됩니다. */ -import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; export default function NodeEditorPage() { - return ( -
- {/* 페이지 헤더 */} -
-
-

제어 시스템

-

- 드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다 -

-
-
+ const router = useRouter(); - {/* 에디터 */} - + useEffect(() => { + // /admin/dataflow 메인 페이지로 리다이렉트 + router.replace("/admin/dataflow"); + }, [router]); + + return ( +
+
제어 관리 페이지로 이동중...
); } diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/dataflow/page.tsx index a5f25a97..ff7e5aeb 100644 --- a/frontend/app/(main)/admin/dataflow/page.tsx +++ b/frontend/app/(main)/admin/dataflow/page.tsx @@ -2,102 +2,78 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner"; import DataFlowList from "@/components/dataflow/DataFlowList"; -// 🎨 새로운 UI 컴포넌트 import -import DataConnectionDesigner from "@/components/dataflow/connection/redesigned/DataConnectionDesigner"; -import { TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow"; +import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor"; import { useAuth } from "@/hooks/useAuth"; -import { loadDataflowRelationship } from "@/lib/api/dataflowSave"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; -type Step = "list" | "design"; +type Step = "list" | "editor"; export default function DataFlowPage() { const { user } = useAuth(); const router = useRouter(); const [currentStep, setCurrentStep] = useState("list"); - const [stepHistory, setStepHistory] = useState(["list"]); - const [editingDiagram, setEditingDiagram] = useState(null); - const [loadedRelationshipData, setLoadedRelationshipData] = useState(null); + const [loadingFlowId, setLoadingFlowId] = useState(null); - // 단계별 제목과 설명 - const stepConfig = { - list: { - title: "데이터 흐름 제어 관리", - description: "생성된 제어들을 확인하고 관리하세요", - icon: "📊", - }, - design: { - title: "새 제어 설계", - description: "테이블 간 데이터 제어를 시각적으로 설계하세요", - icon: "🎨", - }, - }; + // 플로우 불러오기 핸들러 + const handleLoadFlow = async (flowId: number | null) => { + if (flowId === null) { + // 새 플로우 생성 + setLoadingFlowId(null); + setCurrentStep("editor"); + return; + } - // 다음 단계로 이동 - const goToNextStep = (nextStep: Step) => { - setStepHistory((prev) => [...prev, nextStep]); - setCurrentStep(nextStep); - }; + try { + // 기존 플로우 불러오기 + setLoadingFlowId(flowId); + setCurrentStep("editor"); - // 이전 단계로 이동 - const goToPreviousStep = () => { - if (stepHistory.length > 1) { - const newHistory = stepHistory.slice(0, -1); - const previousStep = newHistory[newHistory.length - 1]; - setStepHistory(newHistory); - setCurrentStep(previousStep); + toast.success("플로우를 불러왔습니다."); + } catch (error: any) { + console.error("❌ 플로우 불러오기 실패:", error); + toast.error(error.message || "플로우를 불러오는데 실패했습니다."); } }; - // 특정 단계로 이동 - const goToStep = (step: Step) => { - setCurrentStep(step); - // 해당 단계까지의 히스토리만 유지 - const stepIndex = stepHistory.findIndex((s) => s === step); - if (stepIndex !== -1) { - setStepHistory(stepHistory.slice(0, stepIndex + 1)); - } + // 목록으로 돌아가기 + const handleBackToList = () => { + setCurrentStep("list"); + setLoadingFlowId(null); }; - const handleSave = (relationships: TableRelationship[]) => { - console.log("저장된 제어:", relationships); - // 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연 - setTimeout(() => { - goToStep("list"); - setEditingDiagram(null); - setLoadedRelationshipData(null); - }, 0); - }; + // 에디터 모드일 때는 전체 화면 사용 + const isEditorMode = currentStep === "editor"; - // 제어 수정 핸들러 - const handleDesignDiagram = async (diagram: DataFlowDiagram | null) => { - if (diagram) { - // 기존 제어 수정 - 저장된 제어 정보 로드 - try { - console.log("📖 제어 수정 모드:", diagram); + // 에디터 모드일 때는 레이아웃 없이 전체 화면 사용 + if (isEditorMode) { + return ( +
+
+ {/* 에디터 헤더 */} +
+ +
+

노드 플로우 에디터

+

+ 드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다 +

+
+
- // 저장된 제어 정보 로드 - const relationshipData = await loadDataflowRelationship(diagram.diagramId); - console.log("✅ 제어 정보 로드 완료:", relationshipData); - - setEditingDiagram(diagram); - setLoadedRelationshipData(relationshipData); - goToNextStep("design"); - - toast.success(`"${diagram.diagramName}" 제어를 불러왔습니다.`); - } catch (error: any) { - console.error("❌ 제어 정보 로드 실패:", error); - toast.error(error.message || "제어 정보를 불러오는데 실패했습니다."); - } - } else { - // 새 제어 생성 - 현재 페이지에서 처리 - setEditingDiagram(null); - setLoadedRelationshipData(null); - goToNextStep("design"); - } - }; + {/* 플로우 에디터 */} +
+ +
+
+
+ ); + } return (
@@ -106,32 +82,12 @@ export default function DataFlowPage() {

제어 관리

-

테이블 간 데이터 제어를 시각적으로 설계하고 관리합니다

+

노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다

- {/* 단계별 내용 */} -
- {/* 제어 목록 단계 */} - {currentStep === "list" && } - - {/* 제어 설계 단계 - 🎨 새로운 UI 사용 */} - {currentStep === "design" && ( - { - goToStep("list"); - setEditingDiagram(null); - setLoadedRelationshipData(null); - }} - initialData={ - loadedRelationshipData || { - connectionType: "data_save", - } - } - showBackButton={true} - /> - )} -
+ {/* 플로우 목록 */} +
); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 27adafe3..f91831d5 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -1,20 +1,17 @@ "use client"; -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition, LayoutData } from "@/types/screen"; -import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewerDynamic"; -import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; -import { DynamicWebTypeRenderer } from "@/lib/registry"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; import { EditModal } from "@/components/screen/EditModal"; -import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils"; -// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거 +import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine"; +import { useBreakpoint } from "@/hooks/useBreakpoint"; export default function ScreenViewPage() { const params = useParams(); @@ -25,21 +22,19 @@ export default function ScreenViewPage() { const [layout, setLayout] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [formData, setFormData] = useState>({}); + const [formData, setFormData] = useState>({}); - // 테이블 선택된 행 상태 (화면 레벨에서 관리) - const [selectedRows, setSelectedRows] = useState([]); - const [selectedRowsData, setSelectedRowsData] = useState([]); + // 화면 너비에 따라 Y좌표 유지 여부 결정 + const [preserveYPosition, setPreserveYPosition] = useState(true); - // 테이블 새로고침을 위한 키 상태 - const [refreshKey, setRefreshKey] = useState(0); + const breakpoint = useBreakpoint(); // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ screenId?: number; modalSize?: "sm" | "md" | "lg" | "xl" | "full"; - editData?: any; + editData?: Record; onSave?: () => void; modalTitle?: string; modalDescription?: string; @@ -75,11 +70,11 @@ export default function ScreenViewPage() { setEditModalOpen(true); }; - // @ts-ignore + // @ts-expect-error - CustomEvent type window.addEventListener("openEditModal", handleOpenEditModal); return () => { - // @ts-ignore + // @ts-expect-error - CustomEvent type window.removeEventListener("openEditModal", handleOpenEditModal); }; }, []); @@ -101,8 +96,18 @@ export default function ScreenViewPage() { } catch (layoutError) { console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError); setLayout({ + screenId, components: [], - gridSettings: { columns: 12, gap: 16, padding: 16 }, + gridSettings: { + columns: 12, + gap: 16, + padding: 16, + enabled: true, + size: 8, + color: "#e0e0e0", + opacity: 0.5, + snapToGrid: true, + }, }); } } catch (error) { @@ -119,6 +124,24 @@ export default function ScreenViewPage() { } }, [screenId]); + // 윈도우 크기 변경 감지 - layout이 로드된 후에만 실행 + useEffect(() => { + if (!layout) return; + + const screenWidth = layout?.screenResolution?.width || 1200; + + const handleResize = () => { + const shouldPreserve = window.innerWidth >= screenWidth - 100; + setPreserveYPosition(shouldPreserve); + }; + + window.addEventListener("resize", handleResize); + // 초기 값도 설정 + handleResize(); + + return () => window.removeEventListener("resize", handleResize); + }, [layout]); + if (loading) { return (
@@ -149,264 +172,39 @@ export default function ScreenViewPage() { // 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용 const screenWidth = layout?.screenResolution?.width || 1200; - const screenHeight = layout?.screenResolution?.height || 800; return ( -
- {layout && layout.components.length > 0 ? ( - // 캔버스 컴포넌트들을 정확한 해상도로 표시 -
- {layout.components - .filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함) - .map((component) => { - // 그룹 컴포넌트인 경우 특별 처리 - if (component.type === "group") { - const groupChildren = layout.components.filter((child) => child.parentId === component.id); - - return ( -
- {/* 그룹 제목 */} - {(component as any).title && ( -
- {(component as any).title} -
- )} - - {/* 그룹 내 자식 컴포넌트들 렌더링 */} - {groupChildren.map((child) => ( -
- { - console.log("📝 폼 데이터 변경:", { fieldName, value }); - setFormData((prev) => { - const newFormData = { - ...prev, - [fieldName]: value, - }; - console.log("📊 전체 폼 데이터:", newFormData); - return newFormData; - }); - }} - screenInfo={{ - id: screenId, - tableName: screen?.tableName, - }} - /> -
- ))} -
- ); - } - - // 라벨 표시 여부 계산 - const templateTypes = ["datatable"]; - const shouldShowLabel = - component.style?.labelDisplay !== false && - (component.label || component.style?.labelText) && - !templateTypes.includes(component.type); - - const labelText = component.style?.labelText || component.label || ""; - const labelStyle = { - fontSize: component.style?.labelFontSize || "14px", - color: component.style?.labelColor || "#212121", - fontWeight: component.style?.labelFontWeight || "500", - backgroundColor: component.style?.labelBackgroundColor || "transparent", - padding: component.style?.labelPadding || "0", - borderRadius: component.style?.labelBorderRadius || "0", - marginBottom: component.style?.labelMarginBottom || "4px", - }; - - // 일반 컴포넌트 렌더링 - return ( -
- {/* 라벨을 외부에 별도로 렌더링 */} - {shouldShowLabel && ( -
- {labelText} - {component.required && *} -
- )} - - {/* 실제 컴포넌트 */} -
{ - // console.log("🎯 할당된 화면 컴포넌트:", { - // id: component.id, - // type: component.type, - // position: component.position, - // size: component.size, - // styleWidth: component.style?.width, - // styleHeight: component.style?.height, - // finalWidth: `${component.size.width}px`, - // finalHeight: `${component.size.height}px`, - // }); - }} - > - {/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */} - {component.type !== "widget" ? ( - { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }} - screenId={screenId} - tableName={screen?.tableName} - onRefresh={() => { - console.log("화면 새로고침 요청"); - // 테이블 컴포넌트 강제 새로고침을 위한 키 업데이트 - setRefreshKey((prev) => prev + 1); - // 선택된 행 상태도 초기화 - setSelectedRows([]); - setSelectedRowsData([]); - }} - onClose={() => { - console.log("화면 닫기 요청"); - }} - // 테이블 선택된 행 정보 전달 - selectedRows={selectedRows} - selectedRowsData={selectedRowsData} - onSelectedRowsChange={(newSelectedRows, newSelectedRowsData) => { - setSelectedRows(newSelectedRows); - setSelectedRowsData(newSelectedRowsData); - }} - // 테이블 새로고침 키 전달 - refreshKey={refreshKey} - /> - ) : ( - { - // 유틸리티 함수로 파일 컴포넌트 감지 - if (isFileComponent(component)) { - console.log('🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"', { - componentId: component.id, - componentType: component.type, - originalWebType: component.webType, - }); - return "file"; - } - // 다른 컴포넌트는 유틸리티 함수로 webType 결정 - return getComponentWebType(component) || "text"; - })()} - config={component.webTypeConfig} - props={{ - component: component, - value: formData[component.columnName || component.id] || "", - onChange: (value: any) => { - const fieldName = component.columnName || component.id; - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }, - onFormDataChange: (fieldName, value) => { - console.log(`🎯 page.tsx onFormDataChange 호출: ${fieldName} = "${value}"`); - console.log("📋 현재 formData:", formData); - setFormData((prev) => { - const newFormData = { - ...prev, - [fieldName]: value, - }; - console.log("📝 업데이트된 formData:", newFormData); - return newFormData; - }); - }, - isInteractive: true, - formData: formData, - readonly: component.readonly, - required: component.required, - placeholder: component.placeholder, - className: "w-full h-full", - }} - /> - )} -
-
- ); - })} -
- ) : ( - // 빈 화면일 때도 깔끔하게 표시 -
-
-
- 📄 +
+
+ {/* 항상 반응형 모드로 렌더링 */} + {layout && layout.components.length > 0 ? ( + { + console.log("📝 page.tsx formData 업데이트:", fieldName, value); + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + screenInfo={{ id: screenId, tableName: screen?.tableName }} + /> + ) : ( + // 빈 화면일 때 +
+
+
+ 📄 +
+

화면이 비어있습니다

+

이 화면에는 아직 설계된 컴포넌트가 없습니다.

-

화면이 비어있습니다

-

이 화면에는 아직 설계된 컴포넌트가 없습니다.

-
- )} + )} +
{/* 편집 모달 */} void; +// 노드 플로우 타입 정의 +interface NodeFlow { + flowId: number; + flowName: string; + flowDescription: string; + createdAt: string; + updatedAt: string; } -export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) { +interface DataFlowListProps { + onLoadFlow: (flowId: number | null) => void; +} + +export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { const { user } = useAuth(); - const [diagrams, setDiagrams] = useState([]); + const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [total, setTotal] = useState(0); - - // 사용자 회사 코드 가져오기 (기본값: "*") - const companyCode = user?.company_code || user?.companyCode || "*"; // 모달 상태 const [showCopyModal, setShowCopyModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); - const [selectedDiagramForAction, setSelectedDiagramForAction] = useState(null); + const [selectedFlow, setSelectedFlow] = useState(null); - // 목록 로드 함수 분리 - const loadDiagrams = useCallback(async () => { + // 노드 플로우 목록 로드 + const loadFlows = useCallback(async () => { try { setLoading(true); - const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode); + const response = await apiClient.get("/dataflow/node-flows"); - // JSON API 응답을 기존 형식으로 변환 - const convertedDiagrams = response.diagrams.map((diagram) => { - // relationships 구조 분석 - const relationships = diagram.relationships || {}; - - // 테이블 정보 추출 - const tables: string[] = []; - if (relationships.fromTable?.tableName) { - tables.push(relationships.fromTable.tableName); - } - if ( - relationships.toTable?.tableName && - relationships.toTable.tableName !== relationships.fromTable?.tableName - ) { - tables.push(relationships.toTable.tableName); - } - - // 제어 수 계산 (actionGroups 기준) - const actionGroups = relationships.actionGroups || []; - const relationshipCount = actionGroups.reduce((count: number, group: any) => { - return count + (group.actions?.length || 0); - }, 0); - - return { - diagramId: diagram.diagram_id, - relationshipId: diagram.diagram_id, // 호환성을 위해 추가 - diagramName: diagram.diagram_name, - connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용 - relationshipType: "multi-relationship", // 다중 제어 타입 - relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정 - tableCount: tables.length, - tables: tables, - companyCode: diagram.company_code, // 회사 코드 추가 - createdAt: new Date(diagram.created_at || new Date()), - createdBy: diagram.created_by || "SYSTEM", - updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()), - updatedBy: diagram.updated_by || "SYSTEM", - lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(), - }; - }); - - setDiagrams(convertedDiagrams); - setTotal(response.pagination.total || 0); - setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20))); + if (response.data.success) { + setFlows(response.data.data); + } else { + throw new Error(response.data.message || "플로우 목록 조회 실패"); + } } catch (error) { - console.error("제어 목록 조회 실패", error); - toast.error("제어 목록을 불러오는데 실패했습니다."); + console.error("플로우 목록 조회 실패", error); + toast.error("플로우 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); } - }, [currentPage, searchTerm, companyCode]); + }, []); - // 제어 목록 로드 + // 플로우 목록 로드 useEffect(() => { - loadDiagrams(); - }, [loadDiagrams]); + loadFlows(); + }, [loadFlows]); - const handleDelete = (diagram: DataFlowDiagram) => { - setSelectedDiagramForAction(diagram); + // 플로우 삭제 + const handleDelete = (flow: NodeFlow) => { + setSelectedFlow(flow); setShowDeleteModal(true); }; - const handleCopy = (diagram: DataFlowDiagram) => { - setSelectedDiagramForAction(diagram); - setShowCopyModal(true); - }; - - // 복사 확인 - const handleConfirmCopy = async () => { - if (!selectedDiagramForAction) return; - + // 플로우 복사 + const handleCopy = async (flow: NodeFlow) => { try { setLoading(true); - const copiedDiagram = await DataFlowAPI.copyJsonDataFlowDiagram( - selectedDiagramForAction.diagramId, - companyCode, - undefined, - user?.userId || "SYSTEM", - ); - toast.success(`제어가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`); - // 목록 새로고침 - await loadDiagrams(); + // 원본 플로우 데이터 가져오기 + const response = await apiClient.get(`/dataflow/node-flows/${flow.flowId}`); + + if (!response.data.success) { + throw new Error(response.data.message || "플로우 조회 실패"); + } + + const originalFlow = response.data.data; + + // 복사본 저장 + const copyResponse = await apiClient.post("/dataflow/node-flows", { + flowName: `${flow.flowName} (복사본)`, + flowDescription: flow.flowDescription, + flowData: originalFlow.flowData, + }); + + if (copyResponse.data.success) { + toast.success(`플로우가 성공적으로 복사되었습니다`); + await loadFlows(); + } else { + throw new Error(copyResponse.data.message || "플로우 복사 실패"); + } } catch (error) { - console.error("제어 복사 실패:", error); - toast.error("제어 복사에 실패했습니다."); + console.error("플로우 복사 실패:", error); + toast.error("플로우 복사에 실패했습니다."); } finally { setLoading(false); - setShowCopyModal(false); - setSelectedDiagramForAction(null); } }; // 삭제 확인 const handleConfirmDelete = async () => { - if (!selectedDiagramForAction) return; + if (!selectedFlow) return; try { setLoading(true); - await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode); - toast.success(`제어가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`); + const response = await apiClient.delete(`/dataflow/node-flows/${selectedFlow.flowId}`); - // 목록 새로고침 - await loadDiagrams(); + if (response.data.success) { + toast.success(`플로우가 삭제되었습니다: ${selectedFlow.flowName}`); + await loadFlows(); + } else { + throw new Error(response.data.message || "플로우 삭제 실패"); + } } catch (error) { - console.error("제어 삭제 실패:", error); - toast.error("제어 삭제에 실패했습니다."); + console.error("플로우 삭제 실패:", error); + toast.error("플로우 삭제에 실패했습니다."); } finally { setLoading(false); setShowDeleteModal(false); - setSelectedDiagramForAction(null); + setSelectedFlow(null); } }; - if (loading) { - return ( -
-
로딩 중...
-
- ); - } + // 검색 필터링 + const filteredFlows = flows.filter( + (flow) => + flow.flowName.toLowerCase().includes(searchTerm.toLowerCase()) || + flow.flowDescription.toLowerCase().includes(searchTerm.toLowerCase()), + ); return (
@@ -181,173 +152,125 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
setSearchTerm(e.target.value)} className="w-80 pl-10" />
-
- {/* 제어 목록 테이블 */} + {/* 플로우 목록 테이블 */} - 데이터 흐름 제어 ({total}) + 노드 플로우 목록 ({filteredFlows.length}) - - - - 제어명 - 회사 코드 - 테이블 수 - 액션 수 - 최근 수정 - 작업 - - - - {diagrams.map((diagram) => ( - - -
-
- - {diagram.diagramName} -
-
- 테이블: {diagram.tables.slice(0, 3).join(", ")} - {diagram.tables.length > 3 && ` 외 ${diagram.tables.length - 3}개`} -
-
-
- {diagram.companyCode || "*"} - -
- - {diagram.tableCount} -
-
- -
- - {diagram.relationshipCount} -
-
- -
- - {new Date(diagram.updatedAt).toLocaleDateString()} -
-
- - {diagram.updatedBy} -
-
- - - - - - - onDesignDiagram(diagram)}> - - 수정 - - handleCopy(diagram)}> - - 복사 - - handleDelete(diagram)} className="text-destructive"> - - 삭제 - - - - -
- ))} -
-
- - {diagrams.length === 0 && ( -
- -
제어가 없습니다
-
새 제어를 생성하여 테이블 간 데이터 제어를 설정해보세요.
+ {loading ? ( +
+
로딩 중...
+ ) : ( + <> + + + + 플로우명 + 설명 + 생성일 + 최근 수정 + 작업 + + + + {filteredFlows.map((flow) => ( + onLoadFlow(flow.flowId)} + > + +
+ + {flow.flowName} +
+
+ +
{flow.flowDescription || "설명 없음"}
+
+ +
+ + {new Date(flow.createdAt).toLocaleDateString()} +
+
+ +
+ + {new Date(flow.updatedAt).toLocaleDateString()} +
+
+ e.stopPropagation()}> +
+ + + + + + onLoadFlow(flow.flowId)}> + + 불러오기 + + handleCopy(flow)}> + + 복사 + + handleDelete(flow)} className="text-destructive"> + + 삭제 + + + +
+
+
+ ))} +
+
+ + {filteredFlows.length === 0 && ( +
+ +
플로우가 없습니다
+
새 플로우를 생성하여 노드 기반 데이터 제어를 설계해보세요.
+
+ )} + )} - {/* 페이지네이션 */} - {totalPages > 1 && ( -
- - - {currentPage} / {totalPages} - - -
- )} - - {/* 복사 확인 모달 */} - - - - 제어 복사 - - “{selectedDiagramForAction?.diagramName}” 제어를 복사하시겠습니까? -
- 새로운 제어는 원본 이름 뒤에 (1), (2), (3)... 형태로 생성됩니다. -
-
- - - - -
-
- {/* 삭제 확인 모달 */} - 제어 삭제 + 플로우 삭제 - “{selectedDiagramForAction?.diagramName}” 제어를 완전히 삭제하시겠습니까? + “{selectedFlow?.flowName}” 플로우를 완전히 삭제하시겠습니까?
- 이 작업은 되돌릴 수 없으며, 모든 제어 정보가 영구적으로 삭제됩니다. + 이 작업은 되돌릴 수 없으며, 모든 플로우 정보가 영구적으로 삭제됩니다.
diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 279f64d4..4a5c0903 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -4,12 +4,15 @@ * 노드 기반 플로우 에디터 메인 컴포넌트 */ -import { useCallback, useRef } from "react"; +import { useCallback, useRef, useEffect, useState } from "react"; import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow"; import "reactflow/dist/style.css"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { apiClient } from "@/lib/api/client"; import { NodePalette } from "./sidebar/NodePalette"; +import { LeftUnifiedToolbar, ToolbarButton } from "@/components/screen/toolbar/LeftUnifiedToolbar"; +import { Boxes, Settings } from "lucide-react"; import { PropertiesPanel } from "./panels/PropertiesPanel"; import { FlowToolbar } from "./FlowToolbar"; import { TableSourceNode } from "./nodes/TableSourceNode"; @@ -48,10 +51,38 @@ const nodeTypes = { /** * FlowEditor 내부 컴포넌트 */ -function FlowEditorInner() { +interface FlowEditorInnerProps { + initialFlowId?: number | null; +} + +// 플로우 에디터 툴바 버튼 설정 +const flowToolbarButtons: ToolbarButton[] = [ + { + id: "nodes", + label: "노드", + icon: , + shortcut: "N", + group: "source", + panelWidth: 300, + }, + { + id: "properties", + label: "속성", + icon: , + shortcut: "P", + group: "editor", + panelWidth: 350, + }, +]; + +function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { const reactFlowWrapper = useRef(null); const { screenToFlowPosition } = useReactFlow(); + // 패널 표시 상태 + const [showNodesPanel, setShowNodesPanel] = useState(true); + const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false); + const { nodes, edges, @@ -61,13 +92,50 @@ function FlowEditorInner() { onNodeDragStart, addNode, showPropertiesPanel, + setShowPropertiesPanel, selectNodes, selectedNodes, removeNodes, undo, redo, + loadFlow, } = useFlowEditorStore(); + // 속성 패널 상태 동기화 + useEffect(() => { + if (selectedNodes.length > 0 && !showPropertiesPanelLocal) { + setShowPropertiesPanelLocal(true); + } + }, [selectedNodes, showPropertiesPanelLocal]); + + // 초기 플로우 로드 + useEffect(() => { + const fetchAndLoadFlow = async () => { + if (initialFlowId) { + try { + const response = await apiClient.get(`/dataflow/node-flows/${initialFlowId}`); + + if (response.data.success && response.data.data) { + const flow = response.data.data; + const flowData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData; + + loadFlow( + flow.flowId, + flow.flowName, + flow.flowDescription || "", + flowData.nodes || [], + flowData.edges || [], + ); + } + } catch (error) { + console.error("플로우 로드 실패:", error); + } + } + }; + + fetchAndLoadFlow(); + }, [initialFlowId]); + /** * 노드 선택 변경 핸들러 */ @@ -178,10 +246,29 @@ function FlowEditorInner() { return (
- {/* 좌측 노드 팔레트 */} -
- -
+ {/* 좌측 통합 툴바 */} + { + if (panelId === "nodes") { + setShowNodesPanel(!showNodesPanel); + } else if (panelId === "properties") { + setShowPropertiesPanelLocal(!showPropertiesPanelLocal); + setShowPropertiesPanel(!showPropertiesPanelLocal); + } + }} + /> + + {/* 노드 라이브러리 패널 */} + {showNodesPanel && ( +
+ +
+ )} {/* 중앙 캔버스 */}
@@ -224,8 +311,8 @@ function FlowEditorInner() {
{/* 우측 속성 패널 */} - {showPropertiesPanel && ( -
+ {showPropertiesPanelLocal && selectedNodes.length > 0 && ( +
)} @@ -236,11 +323,15 @@ function FlowEditorInner() { /** * FlowEditor 메인 컴포넌트 (Provider로 감싸기) */ -export function FlowEditor() { +interface FlowEditorProps { + initialFlowId?: number | null; +} + +export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) { return ( -
+
- +
); diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index 7bcb9443..36d60e98 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -4,14 +4,11 @@ * 플로우 에디터 상단 툴바 */ -import { useState } from "react"; -import { Play, Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, FolderOpen, Download, Trash2 } from "lucide-react"; +import { Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useReactFlow } from "reactflow"; -import { LoadFlowDialog } from "./dialogs/LoadFlowDialog"; -import { getNodeFlow } from "@/lib/api/nodeFlows"; export function FlowToolbar() { const { zoomIn, zoomOut, fitView } = useReactFlow(); @@ -21,7 +18,6 @@ export function FlowToolbar() { validateFlow, saveFlow, exportFlow, - isExecuting, isSaving, selectedNodes, removeNodes, @@ -30,7 +26,6 @@ export function FlowToolbar() { canUndo, canRedo, } = useFlowEditorStore(); - const [showLoadDialog, setShowLoadDialog] = useState(false); const handleValidate = () => { const result = validateFlow(); @@ -62,29 +57,6 @@ export function FlowToolbar() { alert("✅ JSON 파일로 내보내기 완료!"); }; - const handleLoad = async (flowId: number) => { - try { - const flow = await getNodeFlow(flowId); - - // flowData가 이미 객체인지 문자열인지 확인 - const parsedData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData; - - // Zustand 스토어의 loadFlow 함수 호출 - useFlowEditorStore - .getState() - .loadFlow(flow.flowId, flow.flowName, flow.flowDescription, parsedData.nodes, parsedData.edges); - alert(`✅ "${flow.flowName}" 플로우를 불러왔습니다!`); - } catch (error) { - console.error("플로우 불러오기 오류:", error); - alert(error instanceof Error ? error.message : "플로우를 불러올 수 없습니다."); - } - }; - - const handleExecute = () => { - // TODO: 실행 로직 구현 - alert("실행 기능 구현 예정"); - }; - const handleDelete = () => { if (selectedNodes.length === 0) { alert("삭제할 노드를 선택해주세요."); @@ -98,94 +70,74 @@ export function FlowToolbar() { }; return ( - <> - -
- {/* 플로우 이름 */} - setFlowName(e.target.value)} - className="h-8 w-[200px] text-sm" - placeholder="플로우 이름" - /> +
+ {/* 플로우 이름 */} + setFlowName(e.target.value)} + className="h-8 w-[200px] text-sm" + placeholder="플로우 이름" + /> -
+
- {/* 실행 취소/다시 실행 */} - - + {/* 실행 취소/다시 실행 */} + + -
+
- {/* 삭제 버튼 */} - + {/* 삭제 버튼 */} + -
+
- {/* 줌 컨트롤 */} - - - + {/* 줌 컨트롤 */} + + + -
+
- {/* 불러오기 */} - + {/* 저장 */} + - {/* 저장 */} - + {/* 내보내기 */} + - {/* 내보내기 */} - +
-
- - {/* 검증 */} - - - {/* 테스트 실행 */} - -
- + {/* 검증 */} + +
); } diff --git a/frontend/components/dataflow/node-editor/sidebar/NodePalette.tsx b/frontend/components/dataflow/node-editor/sidebar/NodePalette.tsx index c28dfc55..0167069d 100644 --- a/frontend/components/dataflow/node-editor/sidebar/NodePalette.tsx +++ b/frontend/components/dataflow/node-editor/sidebar/NodePalette.tsx @@ -10,7 +10,9 @@ import { NODE_CATEGORIES, getNodesByCategory } from "./nodePaletteConfig"; import type { NodePaletteItem } from "@/types/node-editor"; export function NodePalette() { - const [expandedCategories, setExpandedCategories] = useState>(new Set(["source", "transform", "action"])); + const [expandedCategories, setExpandedCategories] = useState>( + new Set(["source", "transform", "action", "utility"]), + ); const toggleCategory = (categoryId: string) => { setExpandedCategories((prev) => { @@ -25,7 +27,7 @@ export function NodePalette() { }; return ( -
+
{/* 헤더 */}

노드 라이브러리

@@ -46,7 +48,6 @@ export function NodePalette() { className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100" > {isExpanded ? : } - {category.icon} {category.label} {nodes.length} @@ -89,13 +90,8 @@ function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) { title={node.description} >
- {/* 아이콘 */} -
- {node.icon} -
+ {/* 색상 인디케이터 (좌측) */} +
{/* 라벨 및 설명 */}
@@ -104,7 +100,7 @@ function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) {
- {/* 색상 인디케이터 */} + {/* 하단 색상 인디케이터 (hover 시) */}
= ({ const newHeight = Math.min(Math.max(minHeight, contentHeight + headerHeight + padding), maxHeight); // console.log(`🔧 패널 높이 자동 조정:`, { - // panelId: id, - // contentHeight, - // calculatedHeight: newHeight, - // currentHeight: panelSize.height, - // willUpdate: Math.abs(panelSize.height - newHeight) > 10, + // panelId: id, + // contentHeight, + // calculatedHeight: newHeight, + // currentHeight: panelSize.height, + // willUpdate: Math.abs(panelSize.height - newHeight) > 10, // }); // 현재 높이와 다르면 업데이트 @@ -227,7 +227,7 @@ export const FloatingPanel: React.FC = ({
= ({ height: `${panelSize.height}px`, transform: isDragging ? "scale(1.01)" : "scale(1)", transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out", - zIndex: isDragging ? 101 : 100, // 항상 컴포넌트보다 위에 표시 + zIndex: isDragging ? 101 : 100, }} > {/* 헤더 */}
-
- -

{title}

+
+ +

{title}

-
@@ -282,7 +282,7 @@ export const FloatingPanel: React.FC = ({ {/* 리사이즈 핸들 */} {resizable && !autoHeight && (
-
+
)}
diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index fca43a6c..b7f77ab5 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -80,6 +80,12 @@ export const InteractiveScreenViewer: React.FC = ( showValidationPanel = false, validationOptions = {}, }) => { + // component가 없으면 빈 div 반환 + if (!component) { + console.warn("⚠️ InteractiveScreenViewer: component가 undefined입니다."); + return
; + } + const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기 const [localFormData, setLocalFormData] = useState>({}); const [dateValues, setDateValues] = useState>({}); diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index de16484a..cdf74ff4 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -83,9 +83,9 @@ export const RealtimePreviewDynamic: React.FC = ({ // 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래) const selectionStyle = isSelected ? { - outline: "2px solid #3b82f6", + outline: "2px solid hsl(var(--primary))", outlineOffset: "2px", - zIndex: 20, // 패널과 모달보다 낮게 설정 + zIndex: 20, } : {}; @@ -183,16 +183,16 @@ export const RealtimePreviewDynamic: React.FC = ({ {/* 선택된 컴포넌트 정보 표시 */} {isSelected && ( -
+
{type === "widget" && ( -
+
{getWidgetIcon((component as WidgetComponent).widgetType)} - {(component as WidgetComponent).widgetType || "widget"} + {(component as WidgetComponent).widgetType || "widget"}
)} {type !== "widget" && ( -
- {component.componentConfig?.type || type} +
+ {component.componentConfig?.type || type}
)}
diff --git a/frontend/components/screen/ResponsiveLayoutEngine.tsx b/frontend/components/screen/ResponsiveLayoutEngine.tsx new file mode 100644 index 00000000..0dba5c35 --- /dev/null +++ b/frontend/components/screen/ResponsiveLayoutEngine.tsx @@ -0,0 +1,190 @@ +/** + * 반응형 레이아웃 엔진 + * + * 절대 위치로 배치된 컴포넌트들을 반응형 그리드로 변환 + */ + +import React, { useMemo } from "react"; +import { ComponentData } from "@/types/screen-management"; +import { Breakpoint, BREAKPOINTS } from "@/types/responsive"; +import { generateSmartDefaults, ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; + +export interface ResponsiveLayoutEngineProps { + components: ComponentData[]; + breakpoint: Breakpoint; + containerWidth: number; + screenWidth?: number; + preserveYPosition?: boolean; // true: Y좌표 유지 (하이브리드), false: 16px 간격 (반응형) + formData?: Record; + onFormDataChange?: (fieldName: string, value: unknown) => void; + screenInfo?: { id: number; tableName?: string }; +} + +/** + * 반응형 레이아웃 엔진 + * + * 변환 로직: + * 1. Y 위치 기준으로 행(row)으로 그룹화 + * 2. 각 행 내에서 X 위치 기준으로 정렬 + * 3. 반응형 설정 적용 (order, gridColumns, hide) + * 4. CSS Grid로 렌더링 + */ +export const ResponsiveLayoutEngine: React.FC = ({ + components, + breakpoint, + containerWidth, + screenWidth = 1920, + preserveYPosition = false, // 기본값: 반응형 모드 (16px 간격) + formData, + onFormDataChange, + screenInfo, +}) => { + // 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화 + const rows = useMemo(() => { + const sortedComponents = [...components].sort((a, b) => a.position.y - b.position.y); + + const rows: ComponentData[][] = []; + let currentRow: ComponentData[] = []; + let currentRowY = 0; + const ROW_THRESHOLD = 150; // 같은 행으로 간주할 Y 오차 범위 (px) - 여유있게 설정 + + sortedComponents.forEach((comp) => { + if (currentRow.length === 0) { + currentRow.push(comp); + currentRowY = comp.position.y; + } else if (Math.abs(comp.position.y - currentRowY) < ROW_THRESHOLD) { + currentRow.push(comp); + } else { + rows.push(currentRow); + currentRow = [comp]; + currentRowY = comp.position.y; + } + }); + + if (currentRow.length > 0) { + rows.push(currentRow); + } + + return rows; + }, [components]); + + // 2단계: 각 행 내에서 X 위치 기준으로 정렬 + const sortedRows = useMemo(() => { + return rows.map((row) => [...row].sort((a, b) => a.position.x - b.position.x)); + }, [rows]); + + // 3단계: 반응형 설정 적용 + const responsiveComponents = useMemo(() => { + const result = sortedRows.flatMap((row, rowIndex) => + row.map((comp, compIndex) => { + // 컴포넌트에 gridColumns가 이미 설정되어 있으면 그 값 사용 + if ((comp as any).gridColumns !== undefined) { + return { + ...comp, + responsiveDisplay: { + gridColumns: (comp as any).gridColumns, + order: compIndex + 1, + hide: false, + }, + }; + } + + // 반응형 설정이 없으면 자동 생성 + const compWithConfig = ensureResponsiveConfig(comp, screenWidth); + + // 현재 브레이크포인트의 설정 가져오기 (같은 행의 컴포넌트 개수 전달) + const config = compWithConfig.responsiveConfig!.useSmartDefaults + ? generateSmartDefaults(comp, screenWidth, row.length)[breakpoint] + : compWithConfig.responsiveConfig!.responsive?.[breakpoint]; + + const finalConfig = config || generateSmartDefaults(comp, screenWidth, row.length)[breakpoint]; + + return { + ...compWithConfig, + responsiveDisplay: finalConfig, + }; + }), + ); + + return result; + }, [sortedRows, breakpoint, screenWidth]); + + // 4단계: 필터링 및 정렬 + const visibleComponents = useMemo(() => { + return responsiveComponents + .filter((comp) => !comp.responsiveDisplay?.hide) + .sort((a, b) => (a.responsiveDisplay?.order || 0) - (b.responsiveDisplay?.order || 0)); + }, [responsiveComponents]); + + const gridColumns = BREAKPOINTS[breakpoint].columns; + + // 각 행의 Y 위치를 추적 + const rowsWithYPosition = useMemo(() => { + return sortedRows.map((row) => ({ + components: row, + yPosition: Math.min(...row.map((c) => c.position.y)), // 행의 최소 Y 위치 + })); + }, [sortedRows]); + + return ( +
+ {rowsWithYPosition.map((row, rowIndex) => { + const rowComponents = visibleComponents.filter((vc) => row.components.some((rc) => rc.id === vc.id)); + + // Y 좌표 계산: preserveYPosition에 따라 다르게 처리 + let marginTop: string; + if (preserveYPosition) { + // 하이브리드 모드: 원래 Y 좌표 간격 유지 + if (rowIndex === 0) { + marginTop = `${row.yPosition}px`; + } else { + const prevRowY = rowsWithYPosition[rowIndex - 1].yPosition; + const actualGap = row.yPosition - prevRowY; + marginTop = `${actualGap}px`; + } + } else { + // 반응형 모드: 첫 번째는 맨 위부터 시작 (0px), 나머지는 16px 고정 간격 + marginTop = rowIndex === 0 ? "0px" : "16px"; + } + + return ( +
+ {rowComponents.map((comp) => ( +
+ +
+ ))} +
+ ); + })} +
+ ); +}; diff --git a/frontend/components/screen/ResponsivePreviewModal.tsx b/frontend/components/screen/ResponsivePreviewModal.tsx new file mode 100644 index 00000000..d3f80c3c --- /dev/null +++ b/frontend/components/screen/ResponsivePreviewModal.tsx @@ -0,0 +1,148 @@ +"use client"; + +import React, { useState, createContext, useContext } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Monitor, Tablet, Smartphone, X } from "lucide-react"; +import { ComponentData } from "@/types/screen"; +import { ResponsiveLayoutEngine } from "./ResponsiveLayoutEngine"; +import { Breakpoint } from "@/types/responsive"; + +// 미리보기 모달용 브레이크포인트 Context +const PreviewBreakpointContext = createContext(null); + +// 미리보기 모달 내에서 브레이크포인트를 가져오는 훅 +export const usePreviewBreakpoint = (): Breakpoint | null => { + return useContext(PreviewBreakpointContext); +}; + +interface ResponsivePreviewModalProps { + isOpen: boolean; + onClose: () => void; + components: ComponentData[]; + screenWidth: number; +} + +type DevicePreset = { + name: string; + width: number; + height: number; + icon: React.ReactNode; + breakpoint: Breakpoint; +}; + +const DEVICE_PRESETS: DevicePreset[] = [ + { + name: "데스크톱", + width: 1920, + height: 1080, + icon: , + breakpoint: "desktop", + }, + { + name: "태블릿", + width: 768, + height: 1024, + icon: , + breakpoint: "tablet", + }, + { + name: "모바일", + width: 375, + height: 667, + icon: , + breakpoint: "mobile", + }, +]; + +export const ResponsivePreviewModal: React.FC = ({ + isOpen, + onClose, + components, + screenWidth, +}) => { + const [selectedDevice, setSelectedDevice] = useState(DEVICE_PRESETS[0]); + const [scale, setScale] = useState(1); + + // 스케일 계산: 모달 내에서 디바이스가 잘 보이도록 + React.useEffect(() => { + // 모달 내부 너비를 1400px로 가정하고 여백 100px 제외 + const maxWidth = 1300; + const calculatedScale = Math.min(1, maxWidth / selectedDevice.width); + setScale(calculatedScale); + }, [selectedDevice]); + + return ( + + + +
+ 반응형 미리보기 + +
+ + {/* 디바이스 선택 버튼들 */} +
+ {DEVICE_PRESETS.map((device) => ( + + ))} +
+
+ + {/* 미리보기 영역 - Context Provider로 감싸서 브레이크포인트 전달 */} + +
+
+ {/* 디바이스 프레임 헤더 (선택사항) */} +
+
+ {selectedDevice.name} - {selectedDevice.width}×{selectedDevice.height} +
+
스케일: {Math.round(scale * 100)}%
+
+ + {/* 실제 컴포넌트 렌더링 */} +
+ +
+
+
+
+ + {/* 푸터 정보 */} +
+ 💡 Tip: 각 디바이스 버튼을 클릭하여 다양한 화면 크기에서 레이아웃을 확인할 수 있습니다. +
+
+
+ ); +}; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 9bf3d672..7d4f6378 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -37,6 +37,7 @@ import { } from "@/lib/utils/gridUtils"; import { GroupingToolbar } from "./GroupingToolbar"; import { screenApi, tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; import { toast } from "sonner"; import { MenuAssignmentModal } from "./MenuAssignmentModal"; import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; @@ -56,6 +57,7 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel"; import GridPanel from "./panels/GridPanel"; import ResolutionPanel from "./panels/ResolutionPanel"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; +import { ResponsivePreviewModal } from "./ResponsivePreviewModal"; // 새로운 통합 UI 컴포넌트 import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar"; @@ -143,6 +145,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false); const [selectedFileComponent, setSelectedFileComponent] = useState(null); + // 반응형 미리보기 모달 상태 + const [showResponsivePreview, setShowResponsivePreview] = useState(false); + // 해상도 설정 상태 const [screenResolution, setScreenResolution] = useState( SCREEN_RESOLUTIONS[0], // 기본값: Full HD @@ -867,54 +872,58 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } }, []); - // 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회) + // 화면의 기본 테이블 정보 로드 (원래대로 복원) useEffect(() => { - if (selectedScreen?.tableName && selectedScreen.tableName.trim()) { - const loadTable = async () => { - try { - // 선택된 화면의 특정 테이블 정보만 조회 (성능 최적화) - const [columnsResponse, tableLabelResponse] = await Promise.all([ - tableTypeApi.getColumns(selectedScreen.tableName), - tableTypeApi.getTableLabel(selectedScreen.tableName), - ]); + const loadScreenTable = async () => { + const tableName = selectedScreen?.tableName; + if (!tableName) { + setTables([]); + return; + } - const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({ - tableName: col.tableName || selectedScreen.tableName, - columnName: col.columnName || col.column_name, - // 우선순위: displayName(라벨) > columnLabel > column_label > columnName > column_name - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - dataType: col.dataType || col.data_type || col.dbType, - webType: col.webType || col.web_type, - input_type: col.inputType || col.input_type, // 🎯 input_type 필드 추가 - widgetType: col.widgetType || col.widget_type || col.webType || col.web_type, - isNullable: col.isNullable || col.is_nullable, - required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", - columnDefault: col.columnDefault || col.column_default, - characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, - // 코드 카테고리 정보 추가 - codeCategory: col.codeCategory || col.code_category, - codeValue: col.codeValue || col.code_value, - })); + try { + // 테이블 라벨 조회 + const tableListResponse = await tableManagementApi.getTableList(); + const currentTable = + tableListResponse.success && tableListResponse.data + ? tableListResponse.data.find((t) => t.tableName === tableName) + : null; + const tableLabel = currentTable?.displayName || tableName; - const tableInfo: TableInfo = { - tableName: selectedScreen.tableName, - // 테이블 라벨이 있으면 우선 표시, 없으면 테이블명 그대로 - tableLabel: tableLabelResponse.tableLabel || selectedScreen.tableName, - columns: columns, - }; - setTables([tableInfo]); // 단일 테이블 정보만 설정 - } catch (error) { - // console.error("테이블 정보 로드 실패:", error); - toast.error(`테이블 '${selectedScreen.tableName}' 정보를 불러오는데 실패했습니다.`); - } - }; + // 현재 화면의 테이블 컬럼 정보 조회 + const columnsResponse = await tableTypeApi.getColumns(tableName); - loadTable(); - } else { - // 테이블명이 없는 경우 테이블 목록 초기화 - setTables([]); - } - }, [selectedScreen?.tableName]); + const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({ + tableName: col.tableName || tableName, + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type || col.dbType, + webType: col.webType || col.web_type, + input_type: col.inputType || col.input_type, + widgetType: col.widgetType || col.widget_type || col.webType || col.web_type, + isNullable: col.isNullable || col.is_nullable, + required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", + columnDefault: col.columnDefault || col.column_default, + characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, + codeCategory: col.codeCategory || col.code_category, + codeValue: col.codeValue || col.code_value, + })); + + const tableInfo: TableInfo = { + tableName, + tableLabel, + columns, + }; + + setTables([tableInfo]); // 현재 화면의 테이블만 저장 (원래대로) + } catch (error) { + console.error("화면 테이블 정보 로드 실패:", error); + setTables([]); + } + }; + + loadScreenTable(); + }, [selectedScreen?.tableName, selectedScreen?.screenName]); // 화면 레이아웃 로드 useEffect(() => { @@ -942,8 +951,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD migratedComponents: layoutToUse.components.length, sampleComponent: layoutToUse.components[0], }); - - toast.success("레이아웃이 새로운 그리드 시스템으로 자동 변환되었습니다."); } // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화) @@ -1249,9 +1256,42 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD try { setIsSaving(true); + + // 분할 패널 컴포넌트의 rightPanel.tableName 자동 설정 + const updatedComponents = layout.components.map((comp) => { + if (comp.type === "component" && comp.componentType === "split-panel-layout") { + const config = comp.componentConfig || {}; + const rightPanel = config.rightPanel || {}; + const leftPanel = config.leftPanel || {}; + const relationshipType = rightPanel.relation?.type || "detail"; + + // 관계 타입이 detail이면 rightPanel.tableName을 leftPanel.tableName과 동일하게 설정 + if (relationshipType === "detail" && leftPanel.tableName) { + console.log("🔧 분할 패널 자동 수정:", { + componentId: comp.id, + leftTableName: leftPanel.tableName, + rightTableName: leftPanel.tableName, + }); + + return { + ...comp, + componentConfig: { + ...config, + rightPanel: { + ...rightPanel, + tableName: leftPanel.tableName, + }, + }, + }; + } + } + return comp; + }); + // 해상도 정보를 포함한 레이아웃 데이터 생성 const layoutWithResolution = { ...layout, + components: updatedComponents, screenResolution: screenResolution, }; console.log("💾 저장 시작:", { @@ -1940,6 +1980,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD "checkbox-basic": 2, // 체크박스 (16.67%) "radio-basic": 3, // 라디오 (25%) "file-basic": 4, // 파일 (33%) + "file-upload": 4, // 파일 업로드 (33%) + "slider-basic": 3, // 슬라이더 (25%) + "toggle-switch": 2, // 토글 스위치 (16.67%) + "repeater-field-group": 6, // 반복 필드 그룹 (50%) // 표시 컴포넌트 (DISPLAY 카테고리) "label-basic": 2, // 라벨 (16.67%) @@ -1948,6 +1992,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD "badge-basic": 1, // 배지 (8.33%) "alert-basic": 6, // 알림 (50%) "divider-basic": 12, // 구분선 (100%) + "divider-line": 12, // 구분선 (100%) + "accordion-basic": 12, // 아코디언 (100%) + "table-list": 12, // 테이블 리스트 (100%) + "image-display": 4, // 이미지 표시 (33%) + "split-panel-layout": 6, // 분할 패널 레이아웃 (50%) // 액션 컴포넌트 (ACTION 카테고리) "button-basic": 1, // 버튼 (8.33%) @@ -2013,6 +2062,30 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD gridColumns, }); + // 반복 필드 그룹인 경우 테이블의 첫 번째 컬럼을 기본 필드로 추가 + let enhancedDefaultConfig = { ...component.defaultConfig }; + if ( + component.id === "repeater-field-group" && + tables && + tables.length > 0 && + tables[0].columns && + tables[0].columns.length > 0 + ) { + const firstColumn = tables[0].columns[0]; + enhancedDefaultConfig = { + ...enhancedDefaultConfig, + fields: [ + { + name: firstColumn.columnName, + label: firstColumn.columnLabel || firstColumn.columnName, + type: (firstColumn.widgetType as any) || "text", + required: firstColumn.required || false, + placeholder: `${firstColumn.columnLabel || firstColumn.columnName}을(를) 입력하세요`, + }, + ], + }; + } + const newComponent: ComponentData = { id: generateComponentId(), type: "component", // ✅ 새 컴포넌트 시스템 사용 @@ -2025,7 +2098,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD componentConfig: { type: component.id, // 새 컴포넌트 시스템의 ID 사용 webType: component.webType, // 웹타입 정보 추가 - ...component.defaultConfig, + ...enhancedDefaultConfig, }, webTypeConfig: getDefaultWebTypeConfig(component.webType), style: { @@ -3744,14 +3817,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD screenResolution={screenResolution} onBack={onBackToList} onSave={handleSave} - onUndo={undo} - onRedo={redo} - onPreview={() => { - toast.info("미리보기 기능은 준비 중입니다."); - }} - canUndo={historyIndex > 0} - canRedo={historyIndex < history.length - 1} isSaving={isSaving} + onPreview={() => setShowResponsivePreview(true)} /> {/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
@@ -3869,12 +3936,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ref={canvasContainerRef} className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6" > - {/* Pan 모드 안내 */} - {isPanMode && ( -
- 🖐️ Pan 모드 활성화 - 드래그하여 캔버스 이동 -
- )} + {/* Pan 모드 안내 - 제거됨 */} {/* 줌 레벨 표시 */}
@@ -4204,6 +4266,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD screenId={selectedScreen.screenId} /> )} + {/* 반응형 미리보기 모달 */} + setShowResponsivePreview(false)} + components={layout.components} + screenWidth={screenResolution.width} + />
); } diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index dc6bab87..ea7afd6c 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -26,11 +26,26 @@ import { } from "@/components/ui/alert-dialog"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; import CreateScreenModal from "./CreateScreenModal"; import CopyScreenModal from "./CopyScreenModal"; +import dynamic from "next/dynamic"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { DynamicWebTypeRenderer } from "@/lib/registry"; +import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils"; + +// InteractiveScreenViewer를 동적으로 import (SSR 비활성화) +const InteractiveScreenViewer = dynamic( + () => import("./InteractiveScreenViewer").then((mod) => mod.InteractiveScreenViewer), + { + ssr: false, + loading: () =>
로딩 중...
, + }, +); interface ScreenListProps { onScreenSelect: (screen: ScreenDefinition) => void; @@ -82,6 +97,22 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); + // 편집 관련 상태 + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [screenToEdit, setScreenToEdit] = useState(null); + const [editFormData, setEditFormData] = useState({ + screenName: "", + description: "", + isActive: "Y", + }); + + // 미리보기 관련 상태 + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [screenToPreview, setScreenToPreview] = useState(null); + const [previewLayout, setPreviewLayout] = useState(null); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + const [previewFormData, setPreviewFormData] = useState>({}); + // 화면 목록 로드 (실제 API) useEffect(() => { let abort = false; @@ -138,8 +169,42 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr }; const handleEdit = (screen: ScreenDefinition) => { - // 편집 모달 열기 - // console.log("편집:", screen); + setScreenToEdit(screen); + setEditFormData({ + screenName: screen.screenName, + description: screen.description || "", + isActive: screen.isActive, + }); + setEditDialogOpen(true); + }; + + const handleEditSave = async () => { + if (!screenToEdit) return; + + try { + // 화면 정보 업데이트 API 호출 + await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData); + + // 목록에서 해당 화면 정보 업데이트 + setScreens((prev) => + prev.map((s) => + s.screenId === screenToEdit.screenId + ? { + ...s, + screenName: editFormData.screenName, + description: editFormData.description, + isActive: editFormData.isActive, + } + : s, + ), + ); + + setEditDialogOpen(false); + setScreenToEdit(null); + } catch (error) { + console.error("화면 정보 업데이트 실패:", error); + alert("화면 정보 업데이트에 실패했습니다."); + } }; const handleDelete = async (screen: ScreenDefinition) => { @@ -295,9 +360,26 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr setIsCopyOpen(true); }; - const handleView = (screen: ScreenDefinition) => { - // 미리보기 모달 열기 - // console.log("미리보기:", screen); + const handleView = async (screen: ScreenDefinition) => { + setScreenToPreview(screen); + setPreviewLayout(null); // 이전 레이아웃 초기화 + setIsLoadingPreview(true); + setPreviewDialogOpen(true); // 모달 먼저 열기 + + // 모달이 열린 후에 레이아웃 로드 + setTimeout(async () => { + try { + // 화면 레이아웃 로드 + const layoutData = await screenApi.getLayout(screen.screenId); + console.log("📊 미리보기 레이아웃 로드:", layoutData); + setPreviewLayout(layoutData); + } catch (error) { + console.error("❌ 레이아웃 로드 실패:", error); + toast.error("화면 레이아웃을 불러오는데 실패했습니다."); + } finally { + setIsLoadingPreview(false); + } + }, 100); // 100ms 딜레이로 모달 애니메이션이 먼저 시작되도록 }; const handleCopySuccess = () => { @@ -329,11 +411,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr />
-
@@ -386,7 +464,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr - {screen.tableLabel || screen.tableName} + + {screen.tableLabel || screen.tableName} + -
{screen.createdDate.toLocaleDateString()}
+
{screen.createdDate.toLocaleDateString()}
{screen.createdBy}
@@ -504,16 +584,18 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr - {screen.tableLabel || screen.tableName} + + {screen.tableLabel || screen.tableName} + -
{screen.deletedDate?.toLocaleDateString()}
+
{screen.deletedDate?.toLocaleDateString()}
-
{screen.deletedBy}
+
{screen.deletedBy}
-
+
{screen.deleteReason || "-"}
@@ -563,7 +645,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr > 이전 - + {currentPage} / {totalPages}