feature/screen-management #146
|
|
@ -0,0 +1,471 @@
|
|||
---
|
||||
description: 스크롤 문제 디버깅 및 해결 가이드 - Flexbox 레이아웃에서 스크롤이 작동하지 않을 때 체계적인 진단과 해결 방법
|
||||
---
|
||||
|
||||
# 스크롤 문제 디버깅 및 해결 가이드
|
||||
|
||||
React/Next.js 프로젝트에서 Flexbox 레이아웃의 스크롤이 작동하지 않을 때 사용하는 체계적인 디버깅 및 해결 방법입니다.
|
||||
|
||||
## 1. 스크롤 문제의 일반적인 원인
|
||||
|
||||
### 근본 원인: Flexbox의 높이 계산 실패
|
||||
|
||||
Flexbox 레이아웃에서 스크롤이 작동하지 않는 이유:
|
||||
|
||||
1. **부모 컨테이너의 높이가 확정되지 않음**: `h-full`은 부모가 명시적인 높이를 가져야만 작동
|
||||
2. **`minHeight: auto` 기본값**: Flex item은 콘텐츠 크기만큼 늘어나려고 함
|
||||
3. **`overflow` 속성 누락**: 부모가 `overflow: hidden`이 없으면 자식이 부모를 밀어냄
|
||||
4. **`display: flex` 누락**: Flex container가 명시적으로 선언되지 않음
|
||||
|
||||
## 2. 디버깅 프로세스
|
||||
|
||||
### 단계 1: 시각적 디버깅 (컬러 테두리)
|
||||
|
||||
문제가 발생한 컴포넌트에 **컬러 테두리**를 추가하여 각 레이어의 실제 크기를 확인:
|
||||
|
||||
```tsx
|
||||
// 최상위 컨테이너 (빨간색)
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
border: "3px solid red", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (파란색) */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: "64px",
|
||||
border: "3px solid blue", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 (초록색) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
border: "3px solid green", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
콘텐츠
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**브라우저에서 확인할 사항:**
|
||||
|
||||
- 🔴 빨간색 테두리가 화면 전체 높이를 차지하는가?
|
||||
- 🔵 파란색 테두리가 고정된 높이를 유지하는가?
|
||||
- 🟢 초록색 테두리가 남은 공간을 차지하는가?
|
||||
|
||||
### 단계 2: 부모 체인 추적
|
||||
|
||||
스크롤이 작동하지 않으면 **부모 컨테이너부터 역순으로 추적**:
|
||||
|
||||
```tsx
|
||||
// ❌ 문제 예시
|
||||
<div className="flex flex-col"> {/* 높이가 확정되지 않음 */}
|
||||
<div className="flex-1"> {/* flex-1이 작동하지 않음 */}
|
||||
<ComponentWithScroll /> {/* 스크롤 실패 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ✅ 해결
|
||||
<div className="flex flex-col h-screen"> {/* 높이 확정 */}
|
||||
<div className="flex-1 overflow-hidden"> {/* overflow 제한 */}
|
||||
<ComponentWithScroll /> {/* 스크롤 성공 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 단계 3: 개발자 도구로 Computed Style 확인
|
||||
|
||||
브라우저 개발자 도구에서 확인:
|
||||
|
||||
1. **Height**: `auto`가 아닌 구체적인 px 값이 있는가?
|
||||
2. **Display**: `flex`가 제대로 적용되었는가?
|
||||
3. **Overflow**: `overflow-y: auto` 또는 `scroll`이 적용되었는가?
|
||||
4. **Min-height**: `minHeight: 0`이 적용되었는가? (Flex item의 경우)
|
||||
|
||||
## 3. 해결 패턴
|
||||
|
||||
### 패턴 A: 최상위 Fixed/Absolute 컨테이너
|
||||
|
||||
```tsx
|
||||
// 페이지 레벨 (예: dataflow/page.tsx)
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 (고정) */}
|
||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 에디터 (flex-1) */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{" "}
|
||||
{/* ⚠️ overflow-hidden 필수! */}
|
||||
<FlowEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- `fixed inset-0`: 뷰포트 전체 차지
|
||||
- `flex h-full flex-col`: Flex column 레이아웃
|
||||
- `flex-1 overflow-hidden`: 자식이 부모를 넘지 못하게 제한
|
||||
|
||||
### 패턴 B: 중첩된 Flex 컨테이너
|
||||
|
||||
```tsx
|
||||
// 컴포넌트 레벨 (예: FlowEditor.tsx)
|
||||
<div
|
||||
className="flex h-full w-full"
|
||||
style={{ height: "100%", overflow: "hidden" }} // ⚠️ 인라인 스타일로 강제
|
||||
>
|
||||
{/* 좌측 사이드바 */}
|
||||
<div className="h-full w-[300px] border-r bg-white">사이드바</div>
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1">캔버스</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex", // ⚠️ Flex 컨테이너 명시
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- 인라인 스타일 `height: '100%'`: Tailwind보다 우선순위 높음
|
||||
- `display: "flex"`: Flex 컨테이너 명시
|
||||
- `overflow: 'hidden'`: 자식 크기 제한
|
||||
|
||||
### 패턴 C: 스크롤 가능 영역
|
||||
|
||||
```tsx
|
||||
// 스크롤 영역 (예: PropertiesPanel.tsx)
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden", // ⚠️ 최상위는 overflow hidden
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (고정) */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0, // ⚠️ 축소 방지
|
||||
height: "64px", // ⚠️ 명시적 높이
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1, // ⚠️ 남은 공간 차지
|
||||
minHeight: 0, // ⚠️ 핵심! Flex item 축소 허용
|
||||
overflowY: "auto", // ⚠️ 세로 스크롤
|
||||
overflowX: "hidden", // ⚠️ 가로 스크롤 방지
|
||||
}}
|
||||
>
|
||||
{/* 실제 콘텐츠 */}
|
||||
<PropertiesContent />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- `flexShrink: 0`: 헤더가 축소되지 않도록 고정
|
||||
- `minHeight: 0`: **가장 중요!** Flex item이 축소되도록 허용
|
||||
- `flex: 1`: 남은 공간 모두 차지
|
||||
- `overflowY: 'auto'`: 콘텐츠가 넘치면 스크롤 생성
|
||||
|
||||
## 4. 왜 `minHeight: 0`이 필요한가?
|
||||
|
||||
### Flexbox의 기본 동작
|
||||
|
||||
```css
|
||||
/* Flexbox의 기본값 */
|
||||
.flex-item {
|
||||
min-height: auto; /* 콘텐츠 크기만큼 늘어남 */
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- Flex item은 **콘텐츠 크기만큼 늘어나려고 함**
|
||||
- `flex: 1`만으로는 **스크롤이 생기지 않고 부모를 밀어냄**
|
||||
- 결과: 스크롤 영역이 화면 밖으로 넘어감
|
||||
|
||||
**해결:**
|
||||
|
||||
```css
|
||||
.flex-item {
|
||||
flex: 1;
|
||||
min-height: 0; /* 축소 허용 → 스크롤 발생 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Tailwind vs 인라인 스타일
|
||||
|
||||
### 언제 인라인 스타일을 사용하는가?
|
||||
|
||||
**Tailwind가 작동하지 않을 때:**
|
||||
|
||||
```tsx
|
||||
// ❌ Tailwind가 작동하지 않음
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
// ✅ 인라인 스타일로 강제
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{ height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
```
|
||||
|
||||
**이유:**
|
||||
|
||||
1. **CSS 특이성**: 인라인 스타일이 가장 높은 우선순위
|
||||
2. **동적 계산**: 브라우저가 직접 해석
|
||||
3. **디버깅 쉬움**: 개발자 도구에서 바로 확인 가능
|
||||
|
||||
## 6. 체크리스트
|
||||
|
||||
스크롤 문제 발생 시 확인할 사항:
|
||||
|
||||
### 레이아웃 체크
|
||||
|
||||
- [ ] 최상위 컨테이너: `fixed` 또는 `absolute`로 높이 확정
|
||||
- [ ] 부모: `flex flex-col h-full`
|
||||
- [ ] 중간 컨테이너: `flex-1 overflow-hidden`
|
||||
- [ ] 스크롤 컨테이너 부모: `display: flex, flexDirection: column, height: 100%`
|
||||
|
||||
### 스크롤 영역 체크
|
||||
|
||||
- [ ] 헤더: `flexShrink: 0` + 명시적 높이
|
||||
- [ ] 스크롤 영역: `flex: 1, minHeight: 0, overflowY: auto`
|
||||
- [ ] 콘텐츠: 자연스러운 높이 (height 제약 없음)
|
||||
|
||||
### 디버깅 체크
|
||||
|
||||
- [ ] 컬러 테두리로 각 레이어의 크기 확인
|
||||
- [ ] 개발자 도구로 Computed Style 확인
|
||||
- [ ] 부모 체인을 역순으로 추적
|
||||
- [ ] `minHeight: 0` 적용 확인
|
||||
|
||||
## 7. 일반적인 실수
|
||||
|
||||
### 실수 1: 부모의 높이 미확정
|
||||
|
||||
```tsx
|
||||
// ❌ 부모의 높이가 auto
|
||||
<div className="flex flex-col">
|
||||
<div className="flex-1">
|
||||
<ScrollComponent /> {/* 작동 안 함 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ✅ 부모의 높이 확정
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollComponent /> {/* 작동 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 2: overflow-hidden 누락
|
||||
|
||||
```tsx
|
||||
// ❌ overflow-hidden 없음
|
||||
<div className="flex-1">
|
||||
<ScrollComponent /> {/* 부모를 밀어냄 */}
|
||||
</div>
|
||||
|
||||
// ✅ overflow-hidden 추가
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollComponent /> {/* 제한됨 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 3: minHeight: 0 누락
|
||||
|
||||
```tsx
|
||||
// ❌ minHeight: 0 없음
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/* 스크롤 안 됨 */}
|
||||
</div>
|
||||
|
||||
// ✅ minHeight: 0 추가
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
{/* 스크롤 됨 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 4: display: flex 누락
|
||||
|
||||
```tsx
|
||||
// ❌ Flex 컨테이너 미지정
|
||||
<div style={{ height: '100%', width: '350px' }}>
|
||||
<PropertiesPanel /> {/* flex-1이 작동 안 함 */}
|
||||
</div>
|
||||
|
||||
// ✅ Flex 컨테이너 명시
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '350px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<PropertiesPanel /> {/* 작동 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## 8. 완전한 예시
|
||||
|
||||
### 전체 레이아웃 구조
|
||||
|
||||
```tsx
|
||||
// 페이지 (dataflow/page.tsx)
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 에디터 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FlowEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 에디터 (FlowEditor.tsx)
|
||||
<div
|
||||
className="flex h-full w-full"
|
||||
style={{ height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
{/* 사이드바 */}
|
||||
<div className="h-full w-[300px] border-r">
|
||||
사이드바
|
||||
</div>
|
||||
|
||||
{/* 캔버스 */}
|
||||
<div className="relative flex-1">
|
||||
캔버스
|
||||
</div>
|
||||
|
||||
{/* 속성 패널 */}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 속성 패널 (PropertiesPanel.tsx)
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '64px'
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 콘텐츠 */}
|
||||
<PropertiesContent />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 9. 요약
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **높이 확정**: 부모 체인의 모든 요소가 명시적인 높이를 가져야 함
|
||||
2. **overflow 제어**: 중간 컨테이너는 `overflow-hidden`으로 자식 제한
|
||||
3. **Flex 명시**: `display: flex` + `flexDirection: column` 명시
|
||||
4. **minHeight: 0**: 스크롤 영역의 Flex item은 반드시 `minHeight: 0` 적용
|
||||
5. **인라인 스타일**: Tailwind가 작동하지 않으면 인라인 스타일 사용
|
||||
|
||||
### 디버깅 순서
|
||||
|
||||
1. 🎨 **컬러 테두리** 추가로 시각적 확인
|
||||
2. 🔍 **개발자 도구**로 Computed Style 확인
|
||||
3. 🔗 **부모 체인** 역순으로 추적
|
||||
4. ✅ **체크리스트** 항목 확인
|
||||
5. 🔧 **패턴 적용** 및 테스트
|
||||
|
||||
### 최종 구조
|
||||
|
||||
```
|
||||
페이지 (fixed inset-0)
|
||||
└─ flex flex-col h-full
|
||||
├─ 헤더 (고정)
|
||||
└─ 컨테이너 (flex-1 overflow-hidden)
|
||||
└─ 에디터 (height: 100%, overflow: hidden)
|
||||
└─ flex row
|
||||
└─ 패널 (display: flex, flexDirection: column)
|
||||
└─ 패널 내부 (height: 100%)
|
||||
├─ 헤더 (flexShrink: 0, height: 64px)
|
||||
└─ 스크롤 (flex: 1, minHeight: 0, overflowY: auto)
|
||||
```
|
||||
|
||||
## 10. 참고 자료
|
||||
|
||||
이 가이드는 다음 파일을 기반으로 작성되었습니다:
|
||||
|
||||
- [dataflow/page.tsx](<mdc:frontend/app/(main)/admin/dataflow/page.tsx>)
|
||||
- [FlowEditor.tsx](mdc:frontend/components/dataflow/node-editor/FlowEditor.tsx)
|
||||
- [PropertiesPanel.tsx](mdc:frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx)
|
||||
|
|
@ -53,6 +53,8 @@ export interface ExecutionContext {
|
|||
nodeResults: Map<string, NodeResult>;
|
||||
executionOrder: string[];
|
||||
buttonContext?: ButtonContext;
|
||||
// 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all)
|
||||
currentNodeDataSourceType?: string;
|
||||
}
|
||||
|
||||
export interface ButtonContext {
|
||||
|
|
@ -677,51 +679,103 @@ export class NodeFlowExecutionService {
|
|||
node: FlowNode,
|
||||
context: ExecutionContext
|
||||
): Promise<any[]> {
|
||||
const { connectionId, tableName, schema, whereConditions } = node.data;
|
||||
const { connectionId, tableName, schema, whereConditions, dataSourceType } =
|
||||
node.data;
|
||||
|
||||
if (!connectionId || !tableName) {
|
||||
throw new Error("외부 DB 연결 정보 또는 테이블명이 설정되지 않았습니다.");
|
||||
}
|
||||
// 🆕 노드의 dataSourceType 확인 (기본값: context-data)
|
||||
const nodeDataSourceType = dataSourceType || "context-data";
|
||||
|
||||
logger.info(`🔌 외부 DB 소스 조회: ${connectionId}.${tableName}`);
|
||||
// 🆕 ExecutionContext에 현재 소스 노드의 dataSourceType 저장
|
||||
context.currentNodeDataSourceType = nodeDataSourceType;
|
||||
|
||||
try {
|
||||
// 연결 풀 서비스 임포트 (동적 임포트로 순환 참조 방지)
|
||||
const { ExternalDbConnectionPoolService } = await import(
|
||||
"./externalDbConnectionPoolService"
|
||||
);
|
||||
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||
logger.info(
|
||||
`🔌 외부 DB 소스 노드 실행: ${connectionId}.${tableName}, dataSourceType=${nodeDataSourceType}`
|
||||
);
|
||||
|
||||
// 스키마 접두사 처리
|
||||
const schemaPrefix = schema ? `${schema}.` : "";
|
||||
const fullTableName = `${schemaPrefix}${tableName}`;
|
||||
|
||||
// WHERE 절 생성
|
||||
let sql = `SELECT * FROM ${fullTableName}`;
|
||||
let params: any[] = [];
|
||||
|
||||
if (whereConditions && whereConditions.length > 0) {
|
||||
const whereResult = this.buildWhereClause(whereConditions);
|
||||
sql += ` ${whereResult.clause}`;
|
||||
params = whereResult.values;
|
||||
// 1. context-data 모드: 외부에서 주입된 데이터 사용
|
||||
if (nodeDataSourceType === "context-data") {
|
||||
if (
|
||||
context.sourceData &&
|
||||
Array.isArray(context.sourceData) &&
|
||||
context.sourceData.length > 0
|
||||
) {
|
||||
logger.info(
|
||||
`📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건`
|
||||
);
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
logger.info(`📊 외부 DB 쿼리 실행: ${sql}`);
|
||||
|
||||
// 연결 풀을 통해 쿼리 실행
|
||||
const result = await poolService.executeQuery(connectionId, sql, params);
|
||||
|
||||
logger.info(
|
||||
`✅ 외부 DB 소스 조회 완료: ${tableName}, ${result.length}건`
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ 외부 DB 소스 조회 실패:`, error);
|
||||
throw new Error(
|
||||
`외부 DB 조회 실패 (연결 ID: ${connectionId}): ${error.message}`
|
||||
logger.warn(
|
||||
`⚠️ context-data 모드이지만 전달된 데이터가 없습니다. 빈 배열 반환.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. table-all 모드: 외부 DB 테이블 전체 데이터 조회
|
||||
if (nodeDataSourceType === "table-all") {
|
||||
if (!connectionId || !tableName) {
|
||||
throw new Error(
|
||||
"외부 DB 연결 정보 또는 테이블명이 설정되지 않았습니다."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 연결 풀 서비스 임포트 (동적 임포트로 순환 참조 방지)
|
||||
const { ExternalDbConnectionPoolService } = await import(
|
||||
"./externalDbConnectionPoolService"
|
||||
);
|
||||
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||
|
||||
// 스키마 접두사 처리
|
||||
const schemaPrefix = schema ? `${schema}.` : "";
|
||||
const fullTableName = `${schemaPrefix}${tableName}`;
|
||||
|
||||
// WHERE 절 생성
|
||||
let sql = `SELECT * FROM ${fullTableName}`;
|
||||
let params: any[] = [];
|
||||
|
||||
if (whereConditions && whereConditions.length > 0) {
|
||||
const whereResult = this.buildWhereClause(whereConditions);
|
||||
sql += ` ${whereResult.clause}`;
|
||||
params = whereResult.values;
|
||||
}
|
||||
|
||||
logger.info(`📊 외부 DB 쿼리 실행: ${sql}`);
|
||||
|
||||
// 연결 풀을 통해 쿼리 실행
|
||||
const result = await poolService.executeQuery(
|
||||
connectionId,
|
||||
sql,
|
||||
params
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`✅ 외부 DB 전체 데이터 조회 완료: ${tableName}, ${result.length}건`
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ 외부 DB 소스 조회 실패:`, error);
|
||||
throw new Error(
|
||||
`외부 DB 조회 실패 (연결 ID: ${connectionId}): ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 알 수 없는 모드 (기본값으로 처리)
|
||||
logger.warn(
|
||||
`⚠️ 알 수 없는 dataSourceType: ${nodeDataSourceType}, context-data로 처리`
|
||||
);
|
||||
|
||||
if (
|
||||
context.sourceData &&
|
||||
Array.isArray(context.sourceData) &&
|
||||
context.sourceData.length > 0
|
||||
) {
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -731,40 +785,74 @@ export class NodeFlowExecutionService {
|
|||
node: FlowNode,
|
||||
context: ExecutionContext
|
||||
): Promise<any[]> {
|
||||
// 🔥 외부에서 주입된 데이터가 있으면 우선 사용
|
||||
const { tableName, schema, whereConditions, dataSourceType } = node.data;
|
||||
|
||||
// 🆕 노드의 dataSourceType 확인 (기본값: context-data)
|
||||
const nodeDataSourceType = dataSourceType || "context-data";
|
||||
|
||||
// 🆕 ExecutionContext에 현재 소스 노드의 dataSourceType 저장
|
||||
context.currentNodeDataSourceType = nodeDataSourceType;
|
||||
|
||||
logger.info(
|
||||
`📊 테이블 소스 노드 실행: ${tableName}, dataSourceType=${nodeDataSourceType}`
|
||||
);
|
||||
|
||||
// 1. context-data 모드: 외부에서 주입된 데이터 사용
|
||||
if (nodeDataSourceType === "context-data") {
|
||||
if (
|
||||
context.sourceData &&
|
||||
Array.isArray(context.sourceData) &&
|
||||
context.sourceData.length > 0
|
||||
) {
|
||||
logger.info(
|
||||
`📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건`
|
||||
);
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`⚠️ context-data 모드이지만 전달된 데이터가 없습니다. 빈 배열 반환.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. table-all 모드: 테이블 전체 데이터 조회
|
||||
if (nodeDataSourceType === "table-all") {
|
||||
if (!tableName) {
|
||||
logger.warn("⚠️ 테이블 소스 노드에 테이블명이 없습니다.");
|
||||
return [];
|
||||
}
|
||||
|
||||
const schemaPrefix = schema ? `${schema}.` : "";
|
||||
const whereResult = whereConditions
|
||||
? this.buildWhereClause(whereConditions)
|
||||
: { clause: "", values: [] };
|
||||
|
||||
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
|
||||
|
||||
const result = await query(sql, whereResult.values);
|
||||
|
||||
logger.info(
|
||||
`📊 테이블 전체 데이터 조회: ${tableName}, ${result.length}건`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. 알 수 없는 모드 (기본값으로 처리)
|
||||
logger.warn(
|
||||
`⚠️ 알 수 없는 dataSourceType: ${nodeDataSourceType}, context-data로 처리`
|
||||
);
|
||||
|
||||
if (
|
||||
context.sourceData &&
|
||||
Array.isArray(context.sourceData) &&
|
||||
context.sourceData.length > 0
|
||||
) {
|
||||
logger.info(
|
||||
`📊 외부 주입 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건`
|
||||
);
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
// 외부 데이터가 없으면 DB 쿼리 실행
|
||||
const { tableName, schema, whereConditions } = node.data;
|
||||
|
||||
if (!tableName) {
|
||||
logger.warn(
|
||||
"⚠️ 테이블 소스 노드에 테이블명이 없고, 외부 데이터도 없습니다."
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const schemaPrefix = schema ? `${schema}.` : "";
|
||||
const whereResult = whereConditions
|
||||
? this.buildWhereClause(whereConditions)
|
||||
: { clause: "", values: [] };
|
||||
|
||||
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
|
||||
|
||||
const result = await query(sql, whereResult.values);
|
||||
|
||||
logger.info(`📊 테이블 소스 조회: ${tableName}, ${result.length}건`);
|
||||
|
||||
return result;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1243,6 +1331,66 @@ export class NodeFlowExecutionService {
|
|||
let updatedCount = 0;
|
||||
const updatedDataArray: any[] = [];
|
||||
|
||||
// 🆕 table-all 모드: 단일 SQL로 일괄 업데이트
|
||||
if (context.currentNodeDataSourceType === "table-all") {
|
||||
console.log("🚀 table-all 모드: 단일 SQL로 일괄 업데이트 시작");
|
||||
|
||||
// 첫 번째 데이터를 참조하여 SET 절 생성
|
||||
const firstData = dataArray[0];
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
console.log("🗺️ 필드 매핑 처리 중...");
|
||||
fieldMappings.forEach((mapping: any) => {
|
||||
const value =
|
||||
mapping.staticValue !== undefined
|
||||
? mapping.staticValue
|
||||
: firstData[mapping.sourceField];
|
||||
|
||||
console.log(
|
||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||
);
|
||||
|
||||
if (mapping.targetField) {
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
// WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함)
|
||||
const whereResult = this.buildWhereClause(
|
||||
whereConditions,
|
||||
firstData,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
values.push(...whereResult.values);
|
||||
|
||||
const sql = `
|
||||
UPDATE ${targetTable}
|
||||
SET ${setClauses.join(", ")}
|
||||
${whereResult.clause}
|
||||
`;
|
||||
|
||||
console.log("📝 실행할 SQL (일괄 처리):", sql);
|
||||
console.log("📊 바인딩 값:", values);
|
||||
|
||||
const result = await txClient.query(sql, values);
|
||||
updatedCount = result.rowCount || 0;
|
||||
|
||||
logger.info(
|
||||
`✅ UPDATE 완료 (내부 DB, 일괄 처리): ${targetTable}, ${updatedCount}건`
|
||||
);
|
||||
|
||||
// 업데이트된 데이터는 원본 배열 반환 (실제 DB에서 다시 조회하지 않음)
|
||||
return dataArray;
|
||||
}
|
||||
|
||||
// 🆕 context-data 모드: 개별 업데이트 (PK 자동 추가)
|
||||
console.log("🎯 context-data 모드: 개별 업데이트 시작");
|
||||
|
||||
for (const data of dataArray) {
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
|
@ -1277,9 +1425,17 @@ export class NodeFlowExecutionService {
|
|||
}
|
||||
});
|
||||
|
||||
const whereResult = this.buildWhereClause(
|
||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
|
||||
const whereResult = this.buildWhereClause(
|
||||
enhancedWhereConditions,
|
||||
data,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
|
|
@ -1310,7 +1466,7 @@ export class NodeFlowExecutionService {
|
|||
return updatedDataArray;
|
||||
};
|
||||
|
||||
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
|
||||
// 🔥 클라이언트가 전달되었으면 사용, 없으면 독립 트랜잭션 생성
|
||||
if (client) {
|
||||
return executeUpdate(client);
|
||||
} else {
|
||||
|
|
@ -1603,9 +1759,51 @@ export class NodeFlowExecutionService {
|
|||
let deletedCount = 0;
|
||||
const deletedDataArray: any[] = [];
|
||||
|
||||
// 🆕 table-all 모드: 단일 SQL로 일괄 삭제
|
||||
if (context.currentNodeDataSourceType === "table-all") {
|
||||
console.log("🚀 table-all 모드: 단일 SQL로 일괄 삭제 시작");
|
||||
|
||||
// 첫 번째 데이터를 참조하여 WHERE 절 생성
|
||||
const firstData = dataArray[0];
|
||||
|
||||
// WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함)
|
||||
const whereResult = this.buildWhereClause(whereConditions, firstData, 1);
|
||||
|
||||
const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`;
|
||||
|
||||
console.log("📝 실행할 SQL (일괄 처리):", sql);
|
||||
console.log("📊 바인딩 값:", whereResult.values);
|
||||
|
||||
const result = await txClient.query(sql, whereResult.values);
|
||||
deletedCount = result.rowCount || 0;
|
||||
|
||||
// 🔥 RETURNING으로 받은 삭제된 데이터 저장
|
||||
if (result.rows && result.rows.length > 0) {
|
||||
deletedDataArray.push(...result.rows);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ DELETE 완료 (내부 DB, 일괄 처리): ${targetTable}, ${deletedCount}건`
|
||||
);
|
||||
|
||||
return deletedDataArray;
|
||||
}
|
||||
|
||||
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
|
||||
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
||||
|
||||
for (const data of dataArray) {
|
||||
console.log("🔍 WHERE 조건 처리 중...");
|
||||
const whereResult = this.buildWhereClause(whereConditions, data, 1);
|
||||
|
||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
|
||||
const whereResult = this.buildWhereClause(enhancedWhereConditions, data, 1);
|
||||
|
||||
const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`;
|
||||
|
||||
|
|
@ -1629,7 +1827,7 @@ export class NodeFlowExecutionService {
|
|||
return deletedDataArray;
|
||||
};
|
||||
|
||||
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
|
||||
// 🔥 클라이언트가 전달되었으면 사용, 없으면 독립 트랜잭션 생성
|
||||
if (client) {
|
||||
return executeDelete(client);
|
||||
} else {
|
||||
|
|
@ -2439,6 +2637,105 @@ export class NodeFlowExecutionService {
|
|||
/**
|
||||
* WHERE 절 생성
|
||||
*/
|
||||
/**
|
||||
* 테이블의 Primary Key 컬럼 조회 (내부 DB - PostgreSQL)
|
||||
*/
|
||||
private static async getPrimaryKeyColumns(
|
||||
tableName: string,
|
||||
schema: string = "public"
|
||||
): Promise<string[]> {
|
||||
const sql = `
|
||||
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
|
||||
ORDER BY array_position(i.indkey, a.attnum);
|
||||
`;
|
||||
|
||||
const fullTableName = schema ? `${schema}.${tableName}` : tableName;
|
||||
|
||||
try {
|
||||
const result = await query(sql, [fullTableName]);
|
||||
const pkColumns = result.map((row: any) => row.column_name);
|
||||
|
||||
if (pkColumns.length > 0) {
|
||||
console.log(`🔑 테이블 ${tableName}의 Primary Key: ${pkColumns.join(", ")}`);
|
||||
} else {
|
||||
console.log(`⚠️ 테이블 ${tableName}에 Primary Key가 없습니다`);
|
||||
}
|
||||
|
||||
return pkColumns;
|
||||
} catch (error) {
|
||||
console.error(`❌ Primary Key 조회 실패 (${tableName}):`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE 조건에 Primary Key 자동 추가 (컨텍스트 데이터 사용 시)
|
||||
*
|
||||
* 테이블의 실제 Primary Key를 자동으로 감지하여 WHERE 조건에 추가
|
||||
*/
|
||||
private static async enhanceWhereConditionsWithPK(
|
||||
whereConditions: any[],
|
||||
data: any,
|
||||
tableName: string,
|
||||
schema: string = "public"
|
||||
): Promise<any[]> {
|
||||
if (!data) {
|
||||
console.log("⚠️ 입력 데이터가 없어 WHERE 조건 자동 추가 불가");
|
||||
return whereConditions || [];
|
||||
}
|
||||
|
||||
// 🔑 테이블의 실제 Primary Key 컬럼 조회
|
||||
const pkColumns = await this.getPrimaryKeyColumns(tableName, schema);
|
||||
|
||||
if (pkColumns.length === 0) {
|
||||
console.log(`⚠️ 테이블 ${tableName}에 Primary Key가 없어 자동 추가 불가`);
|
||||
return whereConditions || [];
|
||||
}
|
||||
|
||||
// 🔍 데이터에 모든 PK 컬럼이 있는지 확인
|
||||
const missingPKColumns = pkColumns.filter(col =>
|
||||
data[col] === undefined || data[col] === null
|
||||
);
|
||||
|
||||
if (missingPKColumns.length > 0) {
|
||||
console.log(
|
||||
`⚠️ 입력 데이터에 Primary Key 컬럼이 없어 자동 추가 불가: ${missingPKColumns.join(", ")}`
|
||||
);
|
||||
return whereConditions || [];
|
||||
}
|
||||
|
||||
// 🔍 이미 WHERE 조건에 모든 PK가 포함되어 있는지 확인
|
||||
const existingFields = new Set(
|
||||
(whereConditions || []).map((cond: any) => cond.field)
|
||||
);
|
||||
const allPKsExist = pkColumns.every(col =>
|
||||
existingFields.has(col) || existingFields.has(`${tableName}.${col}`)
|
||||
);
|
||||
|
||||
if (allPKsExist) {
|
||||
console.log("✅ WHERE 조건에 이미 모든 Primary Key 포함, 추가하지 않음");
|
||||
return whereConditions || [];
|
||||
}
|
||||
|
||||
// 🔥 Primary Key 조건들을 맨 앞에 추가
|
||||
const pkConditions = pkColumns.map(col => ({
|
||||
field: col,
|
||||
operator: 'EQUALS',
|
||||
value: data[col]
|
||||
}));
|
||||
|
||||
const enhanced = [...pkConditions, ...(whereConditions || [])];
|
||||
|
||||
const pkValues = pkColumns.map(col => `${col} = ${data[col]}`).join(", ");
|
||||
console.log(`🔑 WHERE 조건에 Primary Key 자동 추가: ${pkValues}`);
|
||||
|
||||
return enhanced;
|
||||
}
|
||||
|
||||
private static buildWhereClause(
|
||||
conditions: any[],
|
||||
data?: any,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ services:
|
|||
DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||
JWT_EXPIRES_IN: 24h
|
||||
CORS_ORIGIN: https://v1.vexplor.com
|
||||
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
||||
CORS_CREDENTIALS: "true"
|
||||
LOG_LEVEL: info
|
||||
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export default function DataFlowPage() {
|
|||
</div>
|
||||
|
||||
{/* 플로우 에디터 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FlowEditor key={loadingFlowId || "new"} initialFlowId={loadingFlowId} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import { toast } from "sonner";
|
|||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { EditModal } from "@/components/screen/EditModal";
|
||||
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
|
||||
import { FlowButtonGroup } from "@/components/screen/widgets/FlowButtonGroup";
|
||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
|
||||
export default function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -219,98 +223,247 @@ export default function ScreenViewPage() {
|
|||
}}
|
||||
>
|
||||
{/* 최상위 컴포넌트들 렌더링 */}
|
||||
{layout.components
|
||||
.filter((component) => !component.parentId)
|
||||
.map((component) => (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||
console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", {
|
||||
dataCount: selectedData.length,
|
||||
selectedData,
|
||||
stepId,
|
||||
});
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
console.log("🔍 [page.tsx] 상태 업데이트 완료");
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
flowRefreshKey={flowRefreshKey}
|
||||
onFlowRefresh={() => {
|
||||
console.log("🔄 플로우 새로고침 요청됨");
|
||||
setFlowRefreshKey((prev) => prev + 1);
|
||||
setFlowSelectedData([]); // 선택 해제
|
||||
setFlowSelectedStepId(null);
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 폼 데이터 변경:", fieldName, "=", value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - component.position.x,
|
||||
y: child.position.y - component.position.y,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
};
|
||||
{(() => {
|
||||
// 🆕 플로우 버튼 그룹 감지 및 처리
|
||||
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={child.id}
|
||||
component={relativeChildComponent}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
))}
|
||||
const buttonGroups: Record<string, any[]> = {};
|
||||
const processedButtonIds = new Set<string>();
|
||||
|
||||
topLevelComponents.forEach((component) => {
|
||||
const isButton =
|
||||
component.type === "button" ||
|
||||
(component.type === "component" &&
|
||||
["button-primary", "button-secondary"].includes((component as any).componentType));
|
||||
|
||||
if (isButton) {
|
||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
|
||||
| FlowVisibilityConfig
|
||||
| undefined;
|
||||
|
||||
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
|
||||
if (!buttonGroups[flowConfig.groupId]) {
|
||||
buttonGroups[flowConfig.groupId] = [];
|
||||
}
|
||||
buttonGroups[flowConfig.groupId].push(component);
|
||||
processedButtonIds.add(component.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||
console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", {
|
||||
dataCount: selectedData.length,
|
||||
selectedData,
|
||||
stepId,
|
||||
});
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
console.log("🔍 [page.tsx] 상태 업데이트 완료");
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
flowRefreshKey={flowRefreshKey}
|
||||
onFlowRefresh={() => {
|
||||
console.log("🔄 플로우 새로고침 요청됨");
|
||||
setFlowRefreshKey((prev) => prev + 1);
|
||||
setFlowSelectedData([]); // 선택 해제
|
||||
setFlowSelectedStepId(null);
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 폼 데이터 변경:", fieldName, "=", value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - component.position.x,
|
||||
y: child.position.y - component.position.y,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={child.id}
|
||||
component={relativeChildComponent}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
))}
|
||||
|
||||
{/* 🆕 플로우 버튼 그룹들 */}
|
||||
{Object.entries(buttonGroups).map(([groupId, buttons]) => {
|
||||
if (buttons.length === 0) return null;
|
||||
|
||||
const firstButton = buttons[0];
|
||||
const groupConfig = (firstButton as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig;
|
||||
|
||||
// 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
|
||||
const groupPosition = buttons.reduce(
|
||||
(min, button) => ({
|
||||
x: Math.min(min.x, button.position.x),
|
||||
y: Math.min(min.y, button.position.y),
|
||||
z: min.z,
|
||||
}),
|
||||
{ x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 },
|
||||
);
|
||||
|
||||
// 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
|
||||
const direction = groupConfig.groupDirection || "horizontal";
|
||||
const gap = groupConfig.groupGap ?? 8;
|
||||
|
||||
let groupWidth = 0;
|
||||
let groupHeight = 0;
|
||||
|
||||
if (direction === "horizontal") {
|
||||
groupWidth = buttons.reduce((total, button, index) => {
|
||||
const buttonWidth = button.size?.width || 100;
|
||||
const gapWidth = index < buttons.length - 1 ? gap : 0;
|
||||
return total + buttonWidth + gapWidth;
|
||||
}, 0);
|
||||
groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
|
||||
} else {
|
||||
groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
|
||||
groupHeight = buttons.reduce((total, button, index) => {
|
||||
const buttonHeight = button.size?.height || 40;
|
||||
const gapHeight = index < buttons.length - 1 ? gap : 0;
|
||||
return total + buttonHeight + gapHeight;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`flow-button-group-${groupId}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${groupPosition.x}px`,
|
||||
top: `${groupPosition.y}px`,
|
||||
zIndex: groupPosition.z,
|
||||
width: `${groupWidth}px`,
|
||||
height: `${groupHeight}px`,
|
||||
}}
|
||||
>
|
||||
<FlowButtonGroup
|
||||
buttons={buttons}
|
||||
groupConfig={groupConfig}
|
||||
isDesignMode={false}
|
||||
renderButton={(button) => {
|
||||
const relativeButton = {
|
||||
...button,
|
||||
position: { x: 0, y: 0, z: button.position.z || 1 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={button.id}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
width: button.size?.width || 100,
|
||||
height: button.size?.height || 40,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<DynamicComponentRenderer
|
||||
component={relativeButton}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onDataflowComplete={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]);
|
||||
}}
|
||||
flowRefreshKey={flowRefreshKey}
|
||||
onFlowRefresh={() => {
|
||||
setFlowRefreshKey((prev) => prev + 1);
|
||||
setFlowSelectedData([]);
|
||||
setFlowSelectedStepId(null);
|
||||
}}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
// 빈 화면일 때
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* 노드 기반 플로우 에디터 메인 컴포넌트
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { useCallback, useRef, useEffect, useState, useMemo } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ 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 { ValidationNotification } from "./ValidationNotification";
|
||||
import { FlowToolbar } from "./FlowToolbar";
|
||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||
|
|
@ -27,6 +28,8 @@ import { DataTransformNode } from "./nodes/DataTransformNode";
|
|||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||
import { CommentNode } from "./nodes/CommentNode";
|
||||
import { LogNode } from "./nodes/LogNode";
|
||||
import { validateFlow } from "@/lib/utils/flowValidation";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
|
||||
// 노드 타입들
|
||||
const nodeTypes = {
|
||||
|
|
@ -77,7 +80,7 @@ const flowToolbarButtons: ToolbarButton[] = [
|
|||
|
||||
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { screenToFlowPosition, setCenter } = useReactFlow();
|
||||
|
||||
// 패널 표시 상태
|
||||
const [showNodesPanel, setShowNodesPanel] = useState(true);
|
||||
|
|
@ -91,8 +94,6 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
onConnect,
|
||||
onNodeDragStart,
|
||||
addNode,
|
||||
showPropertiesPanel,
|
||||
setShowPropertiesPanel,
|
||||
selectNodes,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
|
|
@ -101,6 +102,26 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
loadFlow,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
// 🆕 실시간 플로우 검증
|
||||
const validations = useMemo<FlowValidation[]>(() => {
|
||||
return validateFlow(nodes, edges);
|
||||
}, [nodes, edges]);
|
||||
|
||||
// 🆕 노드 클릭 핸들러 (검증 패널에서 사용)
|
||||
const handleValidationNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
selectNodes([nodeId]);
|
||||
setCenter(node.position.x + 125, node.position.y + 50, {
|
||||
zoom: 1.5,
|
||||
duration: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
[nodes, selectNodes, setCenter],
|
||||
);
|
||||
|
||||
// 속성 패널 상태 동기화
|
||||
useEffect(() => {
|
||||
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
|
||||
|
|
@ -245,7 +266,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<div className="flex h-full w-full" style={{ height: "100%", overflow: "hidden" }}>
|
||||
{/* 좌측 통합 툴바 */}
|
||||
<LeftUnifiedToolbar
|
||||
buttons={flowToolbarButtons}
|
||||
|
|
@ -258,7 +279,6 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
setShowNodesPanel(!showNodesPanel);
|
||||
} else if (panelId === "properties") {
|
||||
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
|
||||
setShowPropertiesPanel(!showPropertiesPanelLocal);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -273,8 +293,8 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodes={nodes as any}
|
||||
edges={edges as any}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
|
|
@ -305,17 +325,28 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
|
||||
{/* 상단 툴바 */}
|
||||
<Panel position="top-center" className="pointer-events-auto">
|
||||
<FlowToolbar />
|
||||
<FlowToolbar validations={validations} />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
|
||||
<div className="h-full w-[350px] border-l bg-white">
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검증 알림 (우측 상단 플로팅) */}
|
||||
<ValidationNotification validations={validations} onNodeClick={handleValidationNodeClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,27 @@
|
|||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Save, 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 { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog";
|
||||
import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
|
||||
export function FlowToolbar() {
|
||||
interface FlowToolbarProps {
|
||||
validations?: FlowValidation[];
|
||||
}
|
||||
|
||||
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const {
|
||||
flowName,
|
||||
setFlowName,
|
||||
validateFlow,
|
||||
nodes,
|
||||
edges,
|
||||
saveFlow,
|
||||
exportFlow,
|
||||
isSaving,
|
||||
|
|
@ -27,22 +36,31 @@ export function FlowToolbar() {
|
|||
canRedo,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
const handleValidate = () => {
|
||||
const result = validateFlow();
|
||||
if (result.valid) {
|
||||
alert("✅ 검증 성공! 오류가 없습니다.");
|
||||
} else {
|
||||
alert(`❌ 검증 실패\n\n${result.errors.map((e) => `- ${e.message}`).join("\n")}`);
|
||||
}
|
||||
};
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 검증 수행
|
||||
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
|
||||
const summary = summarizeValidations(currentValidations);
|
||||
|
||||
// 오류나 경고가 있으면 다이얼로그 표시
|
||||
if (currentValidations.length > 0) {
|
||||
setShowSaveDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 문제 없으면 바로 저장
|
||||
await performSave();
|
||||
};
|
||||
|
||||
const performSave = async () => {
|
||||
const result = await saveFlow();
|
||||
if (result.success) {
|
||||
alert(`✅ ${result.message}\nFlow ID: ${result.flowId}`);
|
||||
} else {
|
||||
alert(`❌ 저장 실패\n\n${result.message}`);
|
||||
}
|
||||
setShowSaveDialog(false);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
|
|
@ -70,74 +88,76 @@ export function FlowToolbar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<SaveConfirmDialog
|
||||
open={showSaveDialog}
|
||||
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}
|
||||
onConfirm={performSave}
|
||||
onCancel={() => setShowSaveDialog(false)}
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 검증 */}
|
||||
<Button variant="outline" size="sm" onClick={handleValidate} className="gap-1">
|
||||
<FileCheck className="h-4 w-4" />
|
||||
<span className="text-xs">검증</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 검증 결과 알림 (우측 상단 플로팅)
|
||||
*/
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { AlertTriangle, AlertCircle, Info, X, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
import { summarizeValidations } from "@/lib/utils/flowValidation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ValidationNotificationProps {
|
||||
validations: FlowValidation[];
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ValidationNotification = memo(
|
||||
({ validations, onNodeClick, onClose }: ValidationNotificationProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const summary = summarizeValidations(validations);
|
||||
|
||||
if (validations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
"parallel-conflict": "병렬 실행 충돌",
|
||||
"missing-where": "WHERE 조건 누락",
|
||||
"circular-reference": "순환 참조",
|
||||
"data-source-mismatch": "데이터 소스 불일치",
|
||||
"parallel-table-access": "병렬 테이블 접근",
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
// 타입별로 그룹화
|
||||
const groupedValidations = validations.reduce((acc, validation) => {
|
||||
if (!acc[validation.type]) {
|
||||
acc[validation.type] = [];
|
||||
}
|
||||
acc[validation.type].push(validation);
|
||||
return acc;
|
||||
}, {} as Record<string, FlowValidation[]>);
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 top-4 z-50 w-80 animate-in slide-in-from-right-5 duration-300">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 bg-white shadow-2xl",
|
||||
summary.hasBlockingIssues
|
||||
? "border-red-500"
|
||||
: summary.warningCount > 0
|
||||
? "border-yellow-500"
|
||||
: "border-blue-500"
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between p-3",
|
||||
summary.hasBlockingIssues
|
||||
? "bg-red-50"
|
||||
: summary.warningCount > 0
|
||||
? "bg-yellow-50"
|
||||
: "bg-blue-50"
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.hasBlockingIssues ? (
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
) : summary.warningCount > 0 ? (
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
) : (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
플로우 검증
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{summary.errorCount > 0 && (
|
||||
<Badge variant="destructive" className="h-5 text-[10px]">
|
||||
{summary.errorCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">
|
||||
{summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||
{summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="h-6 w-6 p-0 hover:bg-white/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확장된 내용 */}
|
||||
{isExpanded && (
|
||||
<div className="max-h-[60vh] overflow-y-auto border-t">
|
||||
<div className="p-2 space-y-2">
|
||||
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
|
||||
const firstValidation = typeValidations[0];
|
||||
const Icon =
|
||||
firstValidation.severity === "error"
|
||||
? AlertCircle
|
||||
: firstValidation.severity === "warning"
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<div key={type}>
|
||||
{/* 타입 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
|
||||
firstValidation.severity === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: firstValidation.severity === "warning"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{getTypeLabel(type)}
|
||||
<span className="ml-auto">
|
||||
{typeValidations.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 검증 항목들 */}
|
||||
<div className="space-y-1 pl-5">
|
||||
{typeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 text-xs transition-all hover:border-gray-300 hover:bg-white hover:shadow-sm"
|
||||
onClick={() => onNodeClick?.(validation.nodeId)}
|
||||
>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
{validation.message}
|
||||
</p>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-1 text-[10px] text-gray-500">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-blue-600 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
클릭하여 노드 보기 →
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 요약 메시지 (닫혀있을 때) */}
|
||||
{!isExpanded && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<p className="text-xs text-gray-600">
|
||||
{summary.hasBlockingIssues
|
||||
? "⛔ 오류를 해결해야 저장할 수 있습니다"
|
||||
: summary.warningCount > 0
|
||||
? "⚠️ 경고 사항을 확인하세요"
|
||||
: "ℹ️ 정보를 확인하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ValidationNotification.displayName = "ValidationNotification";
|
||||
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 저장 확인 다이얼로그
|
||||
*
|
||||
* 경고가 있을 때 저장 전 확인을 받습니다
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { AlertTriangle, AlertCircle, Info } from "lucide-react";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
import { summarizeValidations } from "@/lib/utils/flowValidation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface SaveConfirmDialogProps {
|
||||
open: boolean;
|
||||
validations: FlowValidation[];
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const SaveConfirmDialog = memo(
|
||||
({ open, validations, onConfirm, onCancel }: SaveConfirmDialogProps) => {
|
||||
const summary = summarizeValidations(validations);
|
||||
|
||||
// 오류가 있으면 저장 불가
|
||||
if (summary.hasBlockingIssues) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onCancel}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
저장할 수 없습니다
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
오류를 수정한 후 다시 시도하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
오류 {summary.errorCount}
|
||||
</Badge>
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
경고 {summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="space-y-2">
|
||||
{validations
|
||||
.filter((v) => v.severity === "error")
|
||||
.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border-2 border-red-200 bg-red-50 p-3"
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium text-red-600">
|
||||
{validation.type}
|
||||
</div>
|
||||
<div className="text-sm text-red-800">
|
||||
{validation.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
위의 오류를 먼저 해결해주세요. 경고는 저장 후에도 확인할 수
|
||||
있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 경고만 있는 경우 - 저장 가능하지만 확인 필요
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onCancel}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
경고가 있습니다
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
플로우에 {summary.warningCount + summary.infoCount}개의 경고가
|
||||
발견되었습니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
경고 {summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
정보 {summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="space-y-2">
|
||||
{validations
|
||||
.filter((v) => v.severity === "warning")
|
||||
.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border-2 border-yellow-200 bg-yellow-50 p-3"
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium text-yellow-600">
|
||||
{validation.type}
|
||||
</div>
|
||||
<div className="text-sm text-yellow-800">
|
||||
{validation.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{validations
|
||||
.filter((v) => v.severity === "info")
|
||||
.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border-2 border-blue-200 bg-blue-50 p-3"
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium text-blue-600">
|
||||
{validation.type}
|
||||
</div>
|
||||
<div className="text-sm text-blue-800">
|
||||
{validation.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-xs text-gray-600">
|
||||
⚠️ 이 경고들은 플로우의 동작에 영향을 줄 수 있습니다.
|
||||
<br />
|
||||
그래도 저장하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="h-8 flex-1 bg-yellow-500 text-xs hover:bg-yellow-600 sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
경고 무시하고 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SaveConfirmDialog.displayName = "SaveConfirmDialog";
|
||||
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 검증 기능이 포함된 노드 래퍼
|
||||
*
|
||||
* 모든 노드에 경고/에러 아이콘을 표시하는 공통 래퍼
|
||||
*/
|
||||
|
||||
import { memo, ReactNode } from "react";
|
||||
import { AlertTriangle, AlertCircle, Info } from "lucide-react";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface NodeWithValidationProps {
|
||||
nodeId: string;
|
||||
validations: FlowValidation[];
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const NodeWithValidation = memo(
|
||||
({ nodeId, validations, children, onClick }: NodeWithValidationProps) => {
|
||||
// 이 노드와 관련된 검증 결과 필터링
|
||||
const nodeValidations = validations.filter(
|
||||
(v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId)
|
||||
);
|
||||
|
||||
// 가장 높은 심각도 결정
|
||||
const hasError = nodeValidations.some((v) => v.severity === "error");
|
||||
const hasWarning = nodeValidations.some((v) => v.severity === "warning");
|
||||
const hasInfo = nodeValidations.some((v) => v.severity === "info");
|
||||
|
||||
if (nodeValidations.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 심각도별 아이콘 및 색상
|
||||
const getIconAndColor = () => {
|
||||
if (hasError) {
|
||||
return {
|
||||
Icon: AlertCircle,
|
||||
bgColor: "bg-red-500",
|
||||
textColor: "text-red-700",
|
||||
borderColor: "border-red-500",
|
||||
hoverBgColor: "hover:bg-red-600",
|
||||
};
|
||||
}
|
||||
if (hasWarning) {
|
||||
return {
|
||||
Icon: AlertTriangle,
|
||||
bgColor: "bg-yellow-500",
|
||||
textColor: "text-yellow-700",
|
||||
borderColor: "border-yellow-500",
|
||||
hoverBgColor: "hover:bg-yellow-600",
|
||||
};
|
||||
}
|
||||
return {
|
||||
Icon: Info,
|
||||
bgColor: "bg-blue-500",
|
||||
textColor: "text-blue-700",
|
||||
borderColor: "border-blue-500",
|
||||
hoverBgColor: "hover:bg-blue-600",
|
||||
};
|
||||
};
|
||||
|
||||
const { Icon, bgColor, textColor, borderColor, hoverBgColor } =
|
||||
getIconAndColor();
|
||||
|
||||
return (
|
||||
<div className="relative" onClick={onClick}>
|
||||
{children}
|
||||
|
||||
{/* 경고 배지 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full ${bgColor} ${hoverBgColor} shadow-lg transition-all hover:scale-110`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-white" />
|
||||
{nodeValidations.length > 1 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-white text-[10px] font-bold shadow-sm">
|
||||
{nodeValidations.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="max-w-xs border-0 p-0"
|
||||
>
|
||||
<div className={`rounded-lg border-2 ${borderColor} bg-white p-3 shadow-lg`}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${textColor}`} />
|
||||
<span className="font-semibold text-gray-900">
|
||||
{hasError
|
||||
? "오류"
|
||||
: hasWarning
|
||||
? "경고"
|
||||
: "정보"} ({nodeValidations.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{nodeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded border-l-2 border-gray-300 bg-gray-50 p-2"
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium text-gray-500">
|
||||
{validation.type}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
{validation.message}
|
||||
</div>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NodeWithValidation.displayName = "NodeWithValidation";
|
||||
|
||||
|
|
@ -28,9 +28,23 @@ export function PropertiesPanel() {
|
|||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '64px'
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">속성</h3>
|
||||
{selectedNode && (
|
||||
|
|
@ -42,8 +56,15 @@ export function PropertiesPanel() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* 내용 - 스크롤 가능 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
{selectedNodes.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 검증 결과 패널
|
||||
*
|
||||
* 모든 검증 결과를 사이드바에 표시
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { AlertTriangle, AlertCircle, Info, ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
import { summarizeValidations } from "@/lib/utils/flowValidation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ValidationPanelProps {
|
||||
validations: FlowValidation[];
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ValidationPanel = memo(
|
||||
({ validations, onNodeClick, onClose }: ValidationPanelProps) => {
|
||||
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set());
|
||||
|
||||
const summary = useMemo(
|
||||
() => summarizeValidations(validations),
|
||||
[validations]
|
||||
);
|
||||
|
||||
// 타입별로 그룹화
|
||||
const groupedValidations = useMemo(() => {
|
||||
const groups = new Map<string, FlowValidation[]>();
|
||||
for (const validation of validations) {
|
||||
if (!groups.has(validation.type)) {
|
||||
groups.set(validation.type, []);
|
||||
}
|
||||
groups.get(validation.type)!.push(validation);
|
||||
}
|
||||
return Array.from(groups.entries()).sort((a, b) => {
|
||||
// 심각도 순으로 정렬
|
||||
const severityOrder = { error: 0, warning: 1, info: 2 };
|
||||
const aSeverity = Math.min(
|
||||
...a[1].map((v) => severityOrder[v.severity])
|
||||
);
|
||||
const bSeverity = Math.min(
|
||||
...b[1].map((v) => severityOrder[v.severity])
|
||||
);
|
||||
return aSeverity - bSeverity;
|
||||
});
|
||||
}, [validations]);
|
||||
|
||||
const toggleExpanded = (type: string) => {
|
||||
setExpandedTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
"parallel-conflict": "병렬 실행 충돌",
|
||||
"missing-where": "WHERE 조건 누락",
|
||||
"circular-reference": "순환 참조",
|
||||
"data-source-mismatch": "데이터 소스 불일치",
|
||||
"parallel-table-access": "병렬 테이블 접근",
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
if (validations.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">검증 결과</h3>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-center">
|
||||
<div>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<Info className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">문제 없음</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
플로우에 문제가 발견되지 않았습니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">검증 결과</h3>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 요약 */}
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{summary.errorCount > 0 && (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
오류 {summary.errorCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
경고 {summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
정보 {summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{summary.hasBlockingIssues && (
|
||||
<p className="mt-2 text-xs text-red-600">
|
||||
⛔ 오류를 해결해야 저장할 수 있습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검증 결과 목록 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{groupedValidations.map(([type, typeValidations]) => {
|
||||
const isExpanded = expandedTypes.has(type);
|
||||
const firstValidation = typeValidations[0];
|
||||
const Icon =
|
||||
firstValidation.severity === "error"
|
||||
? AlertCircle
|
||||
: firstValidation.severity === "warning"
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<div key={type} className="mb-2">
|
||||
{/* 그룹 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleExpanded(type)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg p-3 text-left transition-colors",
|
||||
firstValidation.severity === "error"
|
||||
? "bg-red-50 hover:bg-red-100"
|
||||
: firstValidation.severity === "warning"
|
||||
? "bg-yellow-50 hover:bg-yellow-100"
|
||||
: "bg-blue-50 hover:bg-blue-100"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
firstValidation.severity === "error"
|
||||
? "text-red-600"
|
||||
: firstValidation.severity === "warning"
|
||||
? "text-yellow-600"
|
||||
: "text-blue-600"
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{getTypeLabel(type)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{typeValidations.length}개 발견
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 상세 내용 */}
|
||||
{isExpanded && (
|
||||
<div className="mt-1 space-y-1 pl-6 pr-2">
|
||||
{typeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group cursor-pointer rounded-lg border border-gray-200 bg-white p-3 transition-all hover:border-gray-300 hover:shadow-sm"
|
||||
onClick={() => onNodeClick?.(validation.nodeId)}
|
||||
>
|
||||
<div className="text-xs text-gray-700">
|
||||
{validation.message}
|
||||
</div>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-[10px] text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
클릭하여 노드 보기
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ValidationPanel.displayName = "ValidationPanel";
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ export function CommentProperties({ nodeId, data }: CommentPropertiesProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
<div className="flex items-center gap-2 rounded-md bg-yellow-50 p-2">
|
||||
<MessageSquare className="h-4 w-4 text-yellow-600" />
|
||||
<span className="font-semibold text-yellow-600">주석</span>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2 } from "lucide-react";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
|
@ -183,26 +182,39 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setConditions(conditions.filter((_, i) => i !== index));
|
||||
const newConditions = conditions.filter((_, i) => i !== index);
|
||||
setConditions(newConditions);
|
||||
updateNode(nodeId, {
|
||||
conditions: newConditions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...conditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setConditions(newConditions);
|
||||
updateNode(nodeId, {
|
||||
conditions: newConditions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleLogicChange = (newLogic: "AND" | "OR") => {
|
||||
setLogic(newLogic);
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
conditions,
|
||||
logic,
|
||||
logic: newLogic,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
|
@ -215,7 +227,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
|
|
@ -225,7 +237,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
<Label htmlFor="logic" className="text-xs">
|
||||
조건 로직
|
||||
</Label>
|
||||
<Select value={logic} onValueChange={(value: "AND" | "OR") => setLogic(value)}>
|
||||
<Select value={logic} onValueChange={handleLogicChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -386,12 +398,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
|
|
@ -413,6 +419,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, Wand2, ArrowRight } from "lucide-react";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||
|
|
@ -358,8 +357,8 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
|
|||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
|
||||
<Wand2 className="h-4 w-4 text-indigo-600" />
|
||||
|
|
@ -453,14 +452,7 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpD
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -216,8 +215,8 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 경고 */}
|
||||
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -706,9 +705,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} variant="destructive" className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
||||
|
|
@ -717,6 +713,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">💡 실행 전 WHERE 조건을 꼭 확인하세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import { Database, RefreshCw, Table, FileText } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -43,6 +43,11 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
|||
const [selectedConnectionId, setSelectedConnectionId] = useState<number | undefined>(data.connectionId);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
const [schema, setSchema] = useState(data.schema || "");
|
||||
|
||||
// 🆕 데이터 소스 타입 (기본값: context-data)
|
||||
const [dataSourceType, setDataSourceType] = useState<"context-data" | "table-all">(
|
||||
(data as any).dataSourceType || "context-data"
|
||||
);
|
||||
|
||||
const [connections, setConnections] = useState<ExternalConnection[]>([]);
|
||||
const [tables, setTables] = useState<ExternalTable[]>([]);
|
||||
|
|
@ -200,21 +205,26 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
|||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
connectionId: selectedConnectionId,
|
||||
connectionName: selectedConnection?.connection_name || "",
|
||||
tableName,
|
||||
schema,
|
||||
dbType: selectedConnection?.db_type,
|
||||
displayName: newDisplayName,
|
||||
});
|
||||
toast.success("설정이 저장되었습니다.");
|
||||
};
|
||||
|
||||
/**
|
||||
* 🆕 데이터 소스 타입 변경 핸들러
|
||||
*/
|
||||
const handleDataSourceTypeChange = (newType: "context-data" | "table-all") => {
|
||||
setDataSourceType(newType);
|
||||
updateNode(nodeId, {
|
||||
dataSourceType: newType,
|
||||
});
|
||||
console.log(`✅ 데이터 소스 타입 변경: ${newType}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* DB 타입 정보 */}
|
||||
<div
|
||||
className="rounded-lg border-2 p-4"
|
||||
|
|
@ -302,7 +312,7 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
|||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
|
|
@ -340,6 +350,64 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 데이터 소스 설정 */}
|
||||
{tableName && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">데이터 소스 설정</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">데이터 소스 타입</Label>
|
||||
<Select value={dataSourceType} onValueChange={handleDataSourceTypeChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="데이터 소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="context-data">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">컨텍스트 데이터</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
버튼에서 전달된 데이터 사용
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table-all">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">테이블 전체 데이터</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
외부 DB의 모든 행 조회
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 설명 텍스트 */}
|
||||
<div className="mt-2 rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
{dataSourceType === "context-data" ? (
|
||||
<>
|
||||
<p className="font-medium mb-1">💡 컨텍스트 데이터 모드</p>
|
||||
<p>버튼 실행 시 전달된 데이터를 사용합니다.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium mb-1">📊 테이블 전체 데이터 모드</p>
|
||||
<p>외부 DB의 **모든 행**을 직접 조회합니다.</p>
|
||||
<p className="mt-1 text-orange-600 font-medium">⚠️ 대량 데이터 시 성능 주의</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 정보 */}
|
||||
{columns.length > 0 && (
|
||||
<div>
|
||||
|
|
@ -347,14 +415,16 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
|||
{loadingColumns ? (
|
||||
<p className="text-xs text-gray-500">컬럼 목록 로딩 중... ⏳</p>
|
||||
) : (
|
||||
<div className="max-h-[200px] space-y-1 overflow-y-auto">
|
||||
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
{columns.map((col, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
className="flex items-center justify-between rounded bg-white px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="font-mono text-gray-500">{col.data_type}</span>
|
||||
<span className="truncate font-medium" title={col.column_name}>
|
||||
{col.column_name}
|
||||
</span>
|
||||
<span className="ml-2 shrink-0 font-mono text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -362,14 +432,9 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="rounded p-3 text-xs" style={{ backgroundColor: `${dbInfo.color}15`, color: dbInfo.color }}>
|
||||
💡 외부 DB 연결은 "외부 DB 연결 관리" 메뉴에서 미리 설정해야 합니다.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -49,9 +48,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
|
||||
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
|
||||
const [ignoreDuplicates, setIgnoreDuplicates] = useState(data.options?.ignoreDuplicates || false);
|
||||
|
||||
// 내부 DB 테이블 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
|
|
@ -92,9 +88,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
setDisplayName(data.displayName || data.targetTable);
|
||||
setTargetTable(data.targetTable);
|
||||
setFieldMappings(data.fieldMappings || []);
|
||||
setBatchSize(data.options?.batchSize?.toString() || "");
|
||||
setIgnoreErrors(data.options?.ignoreErrors || false);
|
||||
setIgnoreDuplicates(data.options?.ignoreDuplicates || false);
|
||||
}, [data]);
|
||||
|
||||
// 내부 DB 테이블 목록 로딩
|
||||
|
|
@ -439,11 +432,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
displayName: selectedTable.label,
|
||||
targetTable: selectedTable.tableName,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
|
||||
setTablesOpen(false);
|
||||
|
|
@ -451,31 +439,22 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setFieldMappings([
|
||||
const newMappings = [
|
||||
...fieldMappings,
|
||||
{
|
||||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings: newMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -490,30 +469,40 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else if (field === "targetField") {
|
||||
const targetColumn = targetColumns.find((c) => c.columnName === value);
|
||||
const targetColumn = (() => {
|
||||
if (targetType === "internal") {
|
||||
return targetColumns.find((col) => col.column_name === value);
|
||||
} else if (targetType === "external") {
|
||||
return externalColumns.find((col) => col.column_name === value);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
targetField: value,
|
||||
targetFieldLabel: targetColumn?.columnLabel,
|
||||
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
||||
};
|
||||
} else {
|
||||
newMappings[index] = { ...newMappings[index], [field]: value };
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
[field]: value,
|
||||
};
|
||||
}
|
||||
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
// 즉시 반영 핸들러들
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, { displayName: newDisplayName });
|
||||
};
|
||||
|
||||
const handleFieldMappingsChange = (newMappings: any[]) => {
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||||
|
|
@ -541,18 +530,13 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
}
|
||||
|
||||
updates.fieldMappings = fieldMappings;
|
||||
updates.options = {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
};
|
||||
|
||||
updateNode(nodeId, updates);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 🔥 타겟 타입 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||
|
|
@ -719,11 +703,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
externalDbType: selectedConnection?.db_type,
|
||||
externalTargetTable: undefined,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={externalConnectionsLoading}
|
||||
|
|
@ -763,11 +742,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
externalConnectionId: selectedExternalConnectionId,
|
||||
externalTargetTable: value,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={externalTablesLoading}
|
||||
|
|
@ -1206,56 +1180,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">옵션</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="batchSize" className="text-xs">
|
||||
배치 크기
|
||||
</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={batchSize}
|
||||
onChange={(e) => setBatchSize(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="한 번에 처리할 레코드 수"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ignoreDuplicates"
|
||||
checked={ignoreDuplicates}
|
||||
onCheckedChange={(checked) => setIgnoreDuplicates(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreDuplicates" className="text-xs font-normal">
|
||||
중복 데이터 무시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ignoreErrors"
|
||||
checked={ignoreErrors}
|
||||
onCheckedChange={(checked) => setIgnoreErrors(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreErrors" className="text-xs font-normal">
|
||||
오류 발생 시 계속 진행
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||
✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다.
|
||||
|
|
@ -1263,6 +1187,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
💡 소스 필드가 없으면 정적 값이 사용됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function LogProperties({ nodeId, data }: LogPropertiesProps) {
|
|||
const LevelIcon = selectedLevel?.icon || Info;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
<div className="flex items-center gap-2 rounded-md bg-gray-50 p-2">
|
||||
<FileText className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-semibold text-gray-600">로그</span>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, Search } from "lucide-react";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
|
|
@ -262,8 +261,8 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
|
@ -619,11 +618,6 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
|
|
@ -638,6 +632,6 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
<div className="flex items-center gap-2 rounded-md bg-teal-50 p-2">
|
||||
<Globe className="h-4 w-4 text-teal-600" />
|
||||
<span className="font-semibold text-teal-600">REST API 소스</span>
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, Table, FileText } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
|
@ -34,6 +34,11 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
|||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.tableName);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
|
||||
// 🆕 데이터 소스 타입 (기본값: context-data)
|
||||
const [dataSourceType, setDataSourceType] = useState<"context-data" | "table-all">(
|
||||
(data as any).dataSourceType || "context-data"
|
||||
);
|
||||
|
||||
// 테이블 선택 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
|
|
@ -44,7 +49,8 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
|||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.tableName);
|
||||
setTableName(data.tableName);
|
||||
}, [data.displayName, data.tableName]);
|
||||
setDataSourceType((data as any).dataSourceType || "context-data");
|
||||
}, [data.displayName, data.tableName, (data as any).dataSourceType]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
|
|
@ -145,12 +151,22 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 🆕 데이터 소스 타입 변경 핸들러
|
||||
*/
|
||||
const handleDataSourceTypeChange = (newType: "context-data" | "table-all") => {
|
||||
setDataSourceType(newType);
|
||||
updateNode(nodeId, {
|
||||
dataSourceType: newType,
|
||||
});
|
||||
console.log(`✅ 데이터 소스 타입 변경: ${newType}`);
|
||||
};
|
||||
|
||||
// 현재 선택된 테이블의 라벨 찾기
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === tableName)?.label || tableName;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
|
@ -237,15 +253,77 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 데이터 소스 설정 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">데이터 소스 설정</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">데이터 소스 타입</Label>
|
||||
<Select value={dataSourceType} onValueChange={handleDataSourceTypeChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="데이터 소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="context-data">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">컨텍스트 데이터</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
버튼에서 전달된 데이터 사용 (폼, 선택 항목 등)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table-all">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">테이블 전체 데이터</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
선택한 테이블의 모든 행 조회 (페이징 무관)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 설명 텍스트 */}
|
||||
<div className="mt-2 rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
{dataSourceType === "context-data" ? (
|
||||
<>
|
||||
<p className="font-medium mb-1">💡 컨텍스트 데이터 모드</p>
|
||||
<p>버튼 실행 시 전달된 데이터(폼 데이터, 테이블 선택 항목 등)를 사용합니다.</p>
|
||||
<p className="mt-1 text-blue-600">• 폼 데이터: 1개 레코드</p>
|
||||
<p className="text-blue-600">• 테이블 선택: N개 레코드</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium mb-1">📊 테이블 전체 데이터 모드</p>
|
||||
<p>선택한 테이블의 **모든 행**을 직접 조회합니다.</p>
|
||||
<p className="mt-1 text-orange-600 font-medium">⚠️ 대량 데이터 시 성능 주의</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드</h3>
|
||||
<h3 className="mb-3 text-sm font-semibold">
|
||||
출력 필드 {data.fields && data.fields.length > 0 && `(${data.fields.length}개)`}
|
||||
</h3>
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
<div className="space-y-1 rounded border p-2">
|
||||
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
{data.fields.map((field) => (
|
||||
<div key={field.name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono text-gray-700">{field.name}</span>
|
||||
<span className="text-gray-400">{field.type}</span>
|
||||
<div key={field.name} className="flex items-center justify-between rounded bg-white px-2 py-1.5 text-xs">
|
||||
<span className="truncate font-mono text-gray-700" title={field.name}>
|
||||
{field.name}
|
||||
</span>
|
||||
<span className="ml-2 shrink-0 text-gray-400">{field.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -254,9 +332,6 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">✅ 변경 사항이 즉시 노드에 반영됩니다.</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -65,8 +64,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
|
||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
|
||||
|
||||
// 내부 DB 테이블 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
|
|
@ -108,8 +105,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
setTargetTable(data.targetTable);
|
||||
setFieldMappings(data.fieldMappings || []);
|
||||
setWhereConditions(data.whereConditions || []);
|
||||
setBatchSize(data.options?.batchSize?.toString() || "");
|
||||
setIgnoreErrors(data.options?.ignoreErrors || false);
|
||||
}, [data]);
|
||||
|
||||
// 내부 DB 테이블 목록 로딩
|
||||
|
|
@ -368,41 +363,28 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
targetTable: newTableName,
|
||||
fieldMappings,
|
||||
whereConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
|
||||
setTablesOpen(false);
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setFieldMappings([
|
||||
const newMappings = [
|
||||
...fieldMappings,
|
||||
{
|
||||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings: newMappings,
|
||||
whereConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -428,6 +410,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
}
|
||||
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
// 🔥 타겟 타입 변경 핸들러
|
||||
|
|
@ -459,31 +442,22 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
const newConditions = [
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
staticValue: "",
|
||||
},
|
||||
]);
|
||||
];
|
||||
setWhereConditions(newConditions);
|
||||
updateNode(nodeId, { whereConditions: newConditions });
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||||
setWhereConditions(newConditions);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
whereConditions: newConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
updateNode(nodeId, { whereConditions: newConditions });
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -509,26 +483,29 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
}
|
||||
|
||||
setWhereConditions(newConditions);
|
||||
updateNode(nodeId, { whereConditions: newConditions });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
whereConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
// 즉시 반영 핸들러들
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, { displayName: newDisplayName });
|
||||
};
|
||||
|
||||
const handleFieldMappingsChange = (newMappings: any[]) => {
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleWhereConditionsChange = (newConditions: any[]) => {
|
||||
setWhereConditions(newConditions);
|
||||
updateNode(nodeId, { whereConditions: newConditions });
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
|
@ -1261,45 +1238,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">옵션</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="batchSize" className="text-xs">
|
||||
배치 크기
|
||||
</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={batchSize}
|
||||
onChange={(e) => setBatchSize(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="예: 100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ignoreErrors"
|
||||
checked={ignoreErrors}
|
||||
onCheckedChange={(checked) => setIgnoreErrors(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreErrors" className="cursor-pointer text-xs font-normal">
|
||||
오류 무시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -51,8 +50,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
const [conflictKeys, setConflictKeys] = useState<string[]>(data.conflictKeys || []);
|
||||
const [conflictKeyLabels, setConflictKeyLabels] = useState<string[]>(data.conflictKeyLabels || []);
|
||||
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
|
||||
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||
const [updateOnConflict, setUpdateOnConflict] = useState(data.options?.updateOnConflict ?? true);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
|
|
@ -95,8 +92,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
setConflictKeys(data.conflictKeys || []);
|
||||
setConflictKeyLabels(data.conflictKeyLabels || []);
|
||||
setFieldMappings(data.fieldMappings || []);
|
||||
setBatchSize(data.options?.batchSize?.toString() || "");
|
||||
setUpdateOnConflict(data.options?.updateOnConflict ?? true);
|
||||
}, [data]);
|
||||
|
||||
// 🔥 내부 DB 테이블 목록 로딩
|
||||
|
|
@ -363,10 +358,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
conflictKeys,
|
||||
conflictKeyLabels,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
|
||||
setTablesOpen(false);
|
||||
|
|
@ -380,6 +371,10 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
|
||||
setConflictKeys(newConflictKeys);
|
||||
setConflictKeyLabels(newConflictKeyLabels);
|
||||
updateNode(nodeId, {
|
||||
conflictKeys: newConflictKeys,
|
||||
conflictKeyLabels: newConflictKeyLabels,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -389,48 +384,29 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
|
||||
setConflictKeys(newKeys);
|
||||
setConflictKeyLabels(newLabels);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
conflictKeys: newKeys,
|
||||
conflictKeyLabels: newLabels,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setFieldMappings([
|
||||
const newMappings = [
|
||||
...fieldMappings,
|
||||
{
|
||||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
conflictKeys,
|
||||
conflictKeyLabels,
|
||||
fieldMappings: newMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -456,27 +432,30 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
}
|
||||
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
conflictKeys,
|
||||
conflictKeyLabels,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
// 즉시 반영 핸들러들
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, { displayName: newDisplayName });
|
||||
};
|
||||
|
||||
const handleConflictKeysChange = (newKeys: string[]) => {
|
||||
setConflictKeys(newKeys);
|
||||
updateNode(nodeId, { conflictKeys: newKeys });
|
||||
};
|
||||
|
||||
const handleFieldMappingsChange = (newMappings: any[]) => {
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
|
@ -1114,45 +1093,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">옵션</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="batchSize" className="text-xs">
|
||||
배치 크기
|
||||
</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={batchSize}
|
||||
onChange={(e) => setBatchSize(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="예: 100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="updateOnConflict"
|
||||
checked={updateOnConflict}
|
||||
onCheckedChange={(checked) => setUpdateOnConflict(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="updateOnConflict" className="cursor-pointer text-xs font-normal">
|
||||
충돌 시 업데이트 (ON CONFLICT DO UPDATE)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import { DynamicWebTypeRenderer } from "@/lib/registry";
|
|||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
|
|
|
|||
|
|
@ -27,6 +27,18 @@ interface OptimizedButtonProps {
|
|||
selectedRowsData?: any[];
|
||||
flowSelectedData?: any[];
|
||||
flowSelectedStepId?: number | null;
|
||||
|
||||
// 🆕 테이블 전체 데이터 (table-all 모드용)
|
||||
tableAllData?: any[];
|
||||
|
||||
// 🆕 플로우 스텝 전체 데이터 (flow-step-all 모드용)
|
||||
flowStepAllData?: any[];
|
||||
|
||||
// 🆕 테이블 전체 데이터 로드 콜백 (필요 시 부모에서 제공)
|
||||
onRequestTableAllData?: () => Promise<any[]>;
|
||||
|
||||
// 🆕 플로우 스텝 전체 데이터 로드 콜백 (필요 시 부모에서 제공)
|
||||
onRequestFlowStepAllData?: (stepId: number) => Promise<any[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,6 +62,10 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
selectedRowsData = [],
|
||||
flowSelectedData = [],
|
||||
flowSelectedStepId = null,
|
||||
tableAllData = [],
|
||||
flowStepAllData = [],
|
||||
onRequestTableAllData,
|
||||
onRequestFlowStepAllData,
|
||||
}) => {
|
||||
// 🔥 상태 관리
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
|
@ -161,6 +177,47 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
// 🆕 노드 플로우 방식 실행
|
||||
if (config.dataflowConfig.controlMode === "flow" && config.dataflowConfig.flowConfig) {
|
||||
console.log("🔄 노드 플로우 방식 실행:", config.dataflowConfig.flowConfig);
|
||||
console.log("📊 전달될 데이터 확인:", {
|
||||
controlDataSource: config.dataflowConfig.controlDataSource,
|
||||
formDataKeys: Object.keys(formData),
|
||||
selectedRowsDataLength: selectedRowsData.length,
|
||||
flowSelectedDataLength: flowSelectedData.length,
|
||||
flowSelectedStepId,
|
||||
});
|
||||
|
||||
// 🆕 데이터 소스에 따라 추가 데이터 로드
|
||||
let preparedTableAllData = tableAllData;
|
||||
let preparedFlowStepAllData = flowStepAllData;
|
||||
|
||||
const dataSource = config.dataflowConfig.controlDataSource;
|
||||
|
||||
// table-all 모드일 때 데이터 로드
|
||||
if (dataSource === "table-all" || dataSource === "all-sources") {
|
||||
if (tableAllData.length === 0 && onRequestTableAllData) {
|
||||
console.log("📊 테이블 전체 데이터 로드 중...");
|
||||
try {
|
||||
preparedTableAllData = await onRequestTableAllData();
|
||||
console.log(`✅ 테이블 전체 데이터 ${preparedTableAllData.length}건 로드 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 전체 데이터 로드 실패:", error);
|
||||
toast.error("테이블 전체 데이터를 불러오지 못했습니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flow-step-all 모드일 때 데이터 로드
|
||||
if ((dataSource === "flow-step-all" || dataSource === "all-sources") && flowSelectedStepId) {
|
||||
if (flowStepAllData.length === 0 && onRequestFlowStepAllData) {
|
||||
console.log(`📊 플로우 스텝 ${flowSelectedStepId} 전체 데이터 로드 중...`);
|
||||
try {
|
||||
preparedFlowStepAllData = await onRequestFlowStepAllData(flowSelectedStepId);
|
||||
console.log(`✅ 플로우 스텝 전체 데이터 ${preparedFlowStepAllData.length}건 로드 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ 플로우 스텝 전체 데이터 로드 실패:", error);
|
||||
toast.error("플로우 스텝 전체 데이터를 불러오지 못했습니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flowResult = await executeButtonWithFlow(
|
||||
config.dataflowConfig.flowConfig,
|
||||
|
|
@ -172,7 +229,12 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
formData,
|
||||
selectedRows: selectedRows || [],
|
||||
selectedRowsData: selectedRowsData || [],
|
||||
flowSelectedData: flowSelectedData || [],
|
||||
flowStepId: flowSelectedStepId || undefined,
|
||||
controlDataSource: config.dataflowConfig.controlDataSource,
|
||||
// 🆕 확장된 데이터 소스
|
||||
tableAllData: preparedTableAllData,
|
||||
flowStepAllData: preparedFlowStepAllData,
|
||||
},
|
||||
// 원래 액션 (timing이 before나 after일 때 실행)
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import {
|
|||
interface RealtimePreviewProps {
|
||||
component: ComponentData;
|
||||
isSelected?: boolean;
|
||||
isDesignMode?: boolean;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: () => void;
|
||||
|
|
@ -104,7 +105,7 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
|||
};
|
||||
|
||||
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
||||
const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) => {
|
||||
const WidgetRenderer: React.FC<{ component: ComponentData; isDesignMode?: boolean }> = ({ component, isDesignMode = false }) => {
|
||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||
if (!isWidgetComponent(component)) {
|
||||
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
||||
|
|
@ -151,6 +152,8 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
|||
component: widget,
|
||||
value: undefined, // 미리보기이므로 값은 없음
|
||||
readonly: readonly,
|
||||
isDesignMode,
|
||||
isInteractive: !isDesignMode,
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
/>
|
||||
|
|
@ -215,6 +218,7 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
|
|||
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
component,
|
||||
isSelected = false,
|
||||
isDesignMode = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
|
|
@ -431,7 +435,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
{/* 컴포넌트 타입별 렌더링 */}
|
||||
<div
|
||||
ref={isFlowWidget ? contentRef : undefined}
|
||||
className={isFlowWidget ? "h-auto w-full" : "h-full w-full"}
|
||||
className="h-full w-full"
|
||||
>
|
||||
{/* 영역 타입 */}
|
||||
{type === "area" && renderArea(component, children)}
|
||||
|
|
@ -515,7 +519,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="pointer-events-none h-full w-full">
|
||||
<WidgetRenderer component={component} />
|
||||
<WidgetRenderer component={component} isDesignMode={isDesignMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Database, Cog } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ScreenDefinition,
|
||||
ComponentData,
|
||||
|
|
@ -49,6 +50,7 @@ import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan
|
|||
import StyleEditor from "./StyleEditor";
|
||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||
import FloatingPanel from "./FloatingPanel";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import DesignerToolbar from "./DesignerToolbar";
|
||||
import TablesPanel from "./panels/TablesPanel";
|
||||
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
||||
|
|
@ -58,6 +60,16 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
|||
import GridPanel from "./panels/GridPanel";
|
||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||
import {
|
||||
areAllButtons,
|
||||
generateGroupId,
|
||||
groupButtons,
|
||||
ungroupButtons,
|
||||
findAllButtonGroups,
|
||||
} from "@/lib/utils/flowButtonGroupUtils";
|
||||
import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog";
|
||||
|
||||
// 새로운 통합 UI 컴포넌트
|
||||
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
|
||||
|
|
@ -3234,9 +3246,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
// zoom 스케일을 고려한 좌표 변환
|
||||
const startPoint = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
x: (event.clientX - rect.left) / zoomLevel,
|
||||
y: (event.clientY - rect.top) / zoomLevel,
|
||||
z: 1,
|
||||
};
|
||||
|
||||
|
|
@ -3247,7 +3260,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
wasSelecting: false,
|
||||
});
|
||||
},
|
||||
[dragState.isDragging],
|
||||
[dragState.isDragging, zoomLevel],
|
||||
);
|
||||
|
||||
// 드래그 선택 업데이트
|
||||
|
|
@ -3256,9 +3269,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
if (!selectionDrag.isSelecting || !canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
// zoom 스케일을 고려한 좌표 변환
|
||||
const currentPoint = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
x: (event.clientX - rect.left) / zoomLevel,
|
||||
y: (event.clientY - rect.top) / zoomLevel,
|
||||
z: 1,
|
||||
};
|
||||
|
||||
|
|
@ -3298,7 +3312,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
selectedComponents: selectedIds,
|
||||
}));
|
||||
},
|
||||
[selectionDrag.isSelecting, selectionDrag.startPoint, layout.components],
|
||||
[selectionDrag.isSelecting, selectionDrag.startPoint, layout.components, zoomLevel],
|
||||
);
|
||||
|
||||
// 드래그 선택 종료
|
||||
|
|
@ -3467,6 +3481,127 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
|
||||
}, [clipboard, layout, saveToHistory]);
|
||||
|
||||
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
|
||||
// 🆕 플로우 버튼 그룹 다이얼로그 상태
|
||||
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
|
||||
|
||||
const handleFlowButtonGroup = useCallback(() => {
|
||||
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
|
||||
|
||||
// 선택된 컴포넌트가 없거나 1개 이하면 그룹화 불가
|
||||
if (selectedComponents.length < 2) {
|
||||
toast.error("그룹으로 묶을 버튼을 2개 이상 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 모두 버튼인지 확인
|
||||
if (!areAllButtons(selectedComponents)) {
|
||||
toast.error("버튼 컴포넌트만 그룹으로 묶을 수 있습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 다이얼로그 열기
|
||||
setGroupDialogOpen(true);
|
||||
}, [layout, groupState.selectedComponents]);
|
||||
|
||||
// 🆕 그룹 생성 확인 핸들러
|
||||
const handleGroupConfirm = useCallback(
|
||||
(settings: {
|
||||
direction: "horizontal" | "vertical";
|
||||
gap: number;
|
||||
align: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
}) => {
|
||||
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
|
||||
|
||||
// 고유한 그룹 ID 생성
|
||||
const newGroupId = generateGroupId();
|
||||
|
||||
// 버튼들을 그룹으로 묶기 (설정 포함)
|
||||
const groupedButtons = selectedComponents.map((button) => {
|
||||
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {};
|
||||
|
||||
return {
|
||||
...button,
|
||||
webTypeConfig: {
|
||||
...(button as any).webTypeConfig,
|
||||
flowVisibilityConfig: {
|
||||
...currentConfig,
|
||||
enabled: true,
|
||||
layoutBehavior: "auto-compact",
|
||||
groupId: newGroupId,
|
||||
groupDirection: settings.direction,
|
||||
groupGap: settings.gap,
|
||||
groupAlign: settings.align,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 레이아웃 업데이트
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
const grouped = groupedButtons.find((gb) => gb.id === comp.id);
|
||||
return grouped || comp;
|
||||
});
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
toast.success(`${selectedComponents.length}개의 버튼이 플로우 그룹으로 묶였습니다`, {
|
||||
description: `그룹 ID: ${newGroupId} / ${settings.direction === "horizontal" ? "가로" : "세로"} / ${settings.gap}px 간격`,
|
||||
});
|
||||
|
||||
console.log("✅ 플로우 버튼 그룹 생성 완료:", {
|
||||
groupId: newGroupId,
|
||||
buttonCount: selectedComponents.length,
|
||||
buttons: selectedComponents.map((b) => b.id),
|
||||
settings,
|
||||
});
|
||||
},
|
||||
[layout, groupState.selectedComponents, saveToHistory],
|
||||
);
|
||||
|
||||
// 🆕 플로우 버튼 그룹 해제
|
||||
const handleFlowButtonUngroup = useCallback(() => {
|
||||
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
|
||||
|
||||
if (selectedComponents.length === 0) {
|
||||
toast.error("그룹 해제할 버튼을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 버튼이 아닌 것 필터링
|
||||
const buttons = selectedComponents.filter((comp) => areAllButtons([comp]));
|
||||
|
||||
if (buttons.length === 0) {
|
||||
toast.error("선택된 버튼 중 그룹화된 버튼이 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
// 그룹 해제
|
||||
const ungroupedButtons = ungroupButtons(buttons);
|
||||
|
||||
// 레이아웃 업데이트
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id);
|
||||
return ungrouped || comp;
|
||||
});
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
toast.success(`${buttons.length}개의 버튼 그룹이 해제되었습니다`);
|
||||
}, [layout, groupState.selectedComponents, saveToHistory]);
|
||||
|
||||
// 그룹 생성 (임시 비활성화)
|
||||
const handleGroupCreate = useCallback(
|
||||
(componentIds: string[], title: string, style?: any) => {
|
||||
|
|
@ -4181,6 +4316,86 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
|
||||
🔍 {Math.round(zoomLevel * 100)}%
|
||||
</div>
|
||||
{/* 🆕 플로우 버튼 그룹 제어 (다중 선택 시 표시) */}
|
||||
{groupState.selectedComponents.length >= 2 && (
|
||||
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs text-gray-600">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.29 7 12 12 20.71 7"></polyline>
|
||||
<line x1="12" y1="22" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
<span className="font-medium">{groupState.selectedComponents.length}개 선택됨</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleFlowButtonGroup}
|
||||
disabled={
|
||||
!areAllButtons(layout.components.filter((c) => groupState.selectedComponents.includes(c.id)))
|
||||
}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="9" y1="3" x2="9" y2="21"></line>
|
||||
<line x1="15" y1="3" x2="15" y2="21"></line>
|
||||
</svg>
|
||||
플로우 그룹으로 묶기
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleFlowButtonUngroup}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
그룹 해제
|
||||
</Button>
|
||||
{areAllButtons(layout.components.filter((c) => groupState.selectedComponents.includes(c.id))) ? (
|
||||
<p className="mt-1 text-[10px] text-green-600">✓ 모두 버튼 컴포넌트</p>
|
||||
) : (
|
||||
<p className="mt-1 text-[10px] text-orange-600">⚠ 버튼만 그룹 가능</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
|
||||
<div
|
||||
className="flex justify-center"
|
||||
|
|
@ -4244,208 +4459,387 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
))}
|
||||
|
||||
{/* 컴포넌트들 */}
|
||||
{layout.components
|
||||
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
|
||||
.map((component) => {
|
||||
const children =
|
||||
component.type === "group"
|
||||
? layout.components.filter((child) => child.parentId === component.id)
|
||||
: [];
|
||||
{(() => {
|
||||
// 🆕 플로우 버튼 그룹 감지 및 처리
|
||||
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
||||
|
||||
// 드래그 중 시각적 피드백 (다중 선택 지원)
|
||||
const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id;
|
||||
const isBeingDragged =
|
||||
dragState.isDragging &&
|
||||
dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
|
||||
// auto-compact 모드의 버튼들을 그룹별로 묶기
|
||||
const buttonGroups: Record<string, ComponentData[]> = {};
|
||||
const processedButtonIds = new Set<string>();
|
||||
|
||||
let displayComponent = component;
|
||||
topLevelComponents.forEach((component) => {
|
||||
const isButton =
|
||||
component.type === "button" ||
|
||||
(component.type === "component" &&
|
||||
["button-primary", "button-secondary"].includes((component as any).componentType));
|
||||
|
||||
if (isBeingDragged) {
|
||||
if (isDraggingThis) {
|
||||
// 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트
|
||||
displayComponent = {
|
||||
...component,
|
||||
position: dragState.currentPosition,
|
||||
style: {
|
||||
...component.style,
|
||||
opacity: 0.8,
|
||||
transform: "scale(1.02)",
|
||||
transition: "none",
|
||||
zIndex: 50,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트
|
||||
const originalComponent = dragState.draggedComponents.find(
|
||||
(dragComp) => dragComp.id === component.id,
|
||||
);
|
||||
if (originalComponent) {
|
||||
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
|
||||
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
|
||||
if (isButton) {
|
||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
|
||||
| FlowVisibilityConfig
|
||||
| undefined;
|
||||
|
||||
displayComponent = {
|
||||
...component,
|
||||
position: {
|
||||
x: originalComponent.position.x + deltaX,
|
||||
y: originalComponent.position.y + deltaY,
|
||||
z: originalComponent.position.z || 1,
|
||||
} as Position,
|
||||
style: {
|
||||
...component.style,
|
||||
opacity: 0.8,
|
||||
transition: "none",
|
||||
zIndex: 40, // 주 컴포넌트보다 약간 낮게
|
||||
},
|
||||
};
|
||||
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
|
||||
if (!buttonGroups[flowConfig.groupId]) {
|
||||
buttonGroups[flowConfig.groupId] = [];
|
||||
}
|
||||
buttonGroups[flowConfig.groupId].push(component);
|
||||
processedButtonIds.add(component.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 전역 파일 상태도 key에 포함하여 실시간 리렌더링
|
||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||
const globalFiles = globalFileState[component.id] || [];
|
||||
const componentFiles = (component as any).uploadedFiles || [];
|
||||
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
|
||||
// 그룹에 속하지 않은 일반 컴포넌트들
|
||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`}
|
||||
component={displayComponent}
|
||||
isSelected={
|
||||
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
|
||||
}
|
||||
isDesignMode={true} // 편집 모드로 설정
|
||||
onClick={(e) => handleComponentClick(component, e)}
|
||||
onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
|
||||
onDragStart={(e) => startComponentDrag(component, e)}
|
||||
onDragEnd={endDrag}
|
||||
selectedScreen={selectedScreen}
|
||||
// onZoneComponentDrop 제거
|
||||
onZoneClick={handleZoneClick}
|
||||
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
|
||||
onConfigChange={(config) => {
|
||||
// console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
|
||||
return (
|
||||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => {
|
||||
const children =
|
||||
component.type === "group"
|
||||
? layout.components.filter((child) => child.parentId === component.id)
|
||||
: [];
|
||||
|
||||
// 컴포넌트의 componentConfig 업데이트
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
if (comp.id === component.id) {
|
||||
return {
|
||||
...comp,
|
||||
componentConfig: {
|
||||
...comp.componentConfig,
|
||||
...config,
|
||||
// 드래그 중 시각적 피드백 (다중 선택 지원)
|
||||
const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id;
|
||||
const isBeingDragged =
|
||||
dragState.isDragging &&
|
||||
dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
|
||||
|
||||
let displayComponent = component;
|
||||
|
||||
if (isBeingDragged) {
|
||||
if (isDraggingThis) {
|
||||
// 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트
|
||||
displayComponent = {
|
||||
...component,
|
||||
position: dragState.currentPosition,
|
||||
style: {
|
||||
...component.style,
|
||||
opacity: 0.8,
|
||||
transform: "scale(1.02)",
|
||||
transition: "none",
|
||||
zIndex: 50,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트
|
||||
const originalComponent = dragState.draggedComponents.find(
|
||||
(dragComp) => dragComp.id === component.id,
|
||||
);
|
||||
if (originalComponent) {
|
||||
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
|
||||
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
|
||||
|
||||
displayComponent = {
|
||||
...component,
|
||||
position: {
|
||||
x: originalComponent.position.x + deltaX,
|
||||
y: originalComponent.position.y + deltaY,
|
||||
z: originalComponent.position.z || 1,
|
||||
} as Position,
|
||||
style: {
|
||||
...component.style,
|
||||
opacity: 0.8,
|
||||
transition: "none",
|
||||
zIndex: 40, // 주 컴포넌트보다 약간 낮게
|
||||
},
|
||||
};
|
||||
}
|
||||
return comp;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
};
|
||||
// 전역 파일 상태도 key에 포함하여 실시간 리렌더링
|
||||
const globalFileState =
|
||||
typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||
const globalFiles = globalFileState[component.id] || [];
|
||||
const componentFiles = (component as any).uploadedFiles || [];
|
||||
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`}
|
||||
component={displayComponent}
|
||||
isSelected={
|
||||
selectedComponent?.id === component.id ||
|
||||
groupState.selectedComponents.includes(component.id)
|
||||
}
|
||||
isDesignMode={true} // 편집 모드로 설정
|
||||
onClick={(e) => handleComponentClick(component, e)}
|
||||
onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
|
||||
onDragStart={(e) => startComponentDrag(component, e)}
|
||||
onDragEnd={endDrag}
|
||||
selectedScreen={selectedScreen}
|
||||
// onZoneComponentDrop 제거
|
||||
onZoneClick={handleZoneClick}
|
||||
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
|
||||
onConfigChange={(config) => {
|
||||
// console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
|
||||
|
||||
console.log("✅ 컴포넌트 설정 업데이트 완료:", {
|
||||
componentId: component.id,
|
||||
updatedConfig: config,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
// 자식 컴포넌트에도 드래그 피드백 적용
|
||||
const isChildDraggingThis =
|
||||
dragState.isDragging && dragState.draggedComponent?.id === child.id;
|
||||
const isChildBeingDragged =
|
||||
dragState.isDragging &&
|
||||
dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
|
||||
|
||||
let displayChild = child;
|
||||
|
||||
if (isChildBeingDragged) {
|
||||
if (isChildDraggingThis) {
|
||||
// 주 드래그 자식 컴포넌트
|
||||
displayChild = {
|
||||
...child,
|
||||
position: dragState.currentPosition,
|
||||
style: {
|
||||
...child.style,
|
||||
opacity: 0.8,
|
||||
transform: "scale(1.02)",
|
||||
transition: "none",
|
||||
zIndex: 50,
|
||||
// 컴포넌트의 componentConfig 업데이트
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
if (comp.id === component.id) {
|
||||
return {
|
||||
...comp,
|
||||
componentConfig: {
|
||||
...comp.componentConfig,
|
||||
...config,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// 다른 선택된 자식 컴포넌트들
|
||||
const originalChildComponent = dragState.draggedComponents.find(
|
||||
(dragComp) => dragComp.id === child.id,
|
||||
);
|
||||
if (originalChildComponent) {
|
||||
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
|
||||
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
|
||||
}
|
||||
return comp;
|
||||
});
|
||||
|
||||
displayChild = {
|
||||
...child,
|
||||
position: {
|
||||
x: originalChildComponent.position.x + deltaX,
|
||||
y: originalChildComponent.position.y + deltaY,
|
||||
z: originalChildComponent.position.z || 1,
|
||||
} as Position,
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
console.log("✅ 컴포넌트 설정 업데이트 완료:", {
|
||||
componentId: component.id,
|
||||
updatedConfig: config,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||
{(component.type === "group" ||
|
||||
component.type === "container" ||
|
||||
component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
// 자식 컴포넌트에도 드래그 피드백 적용
|
||||
const isChildDraggingThis =
|
||||
dragState.isDragging && dragState.draggedComponent?.id === child.id;
|
||||
const isChildBeingDragged =
|
||||
dragState.isDragging &&
|
||||
dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
|
||||
|
||||
let displayChild = child;
|
||||
|
||||
if (isChildBeingDragged) {
|
||||
if (isChildDraggingThis) {
|
||||
// 주 드래그 자식 컴포넌트
|
||||
displayChild = {
|
||||
...child,
|
||||
position: dragState.currentPosition,
|
||||
style: {
|
||||
...child.style,
|
||||
opacity: 0.8,
|
||||
transform: "scale(1.02)",
|
||||
transition: "none",
|
||||
zIndex: 50,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// 다른 선택된 자식 컴포넌트들
|
||||
const originalChildComponent = dragState.draggedComponents.find(
|
||||
(dragComp) => dragComp.id === child.id,
|
||||
);
|
||||
if (originalChildComponent) {
|
||||
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
|
||||
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
|
||||
|
||||
displayChild = {
|
||||
...child,
|
||||
position: {
|
||||
x: originalChildComponent.position.x + deltaX,
|
||||
y: originalChildComponent.position.y + deltaY,
|
||||
z: originalChildComponent.position.z || 1,
|
||||
} as Position,
|
||||
style: {
|
||||
...child.style,
|
||||
opacity: 0.8,
|
||||
transition: "none",
|
||||
zIndex: 8888,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...displayChild,
|
||||
position: {
|
||||
x: displayChild.position.x - component.position.x,
|
||||
y: displayChild.position.y - component.position.y,
|
||||
z: displayChild.position.z || 1,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={`${child.id}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => f.objid) || [])}`}
|
||||
component={relativeChildComponent}
|
||||
isSelected={
|
||||
selectedComponent?.id === child.id ||
|
||||
groupState.selectedComponents.includes(child.id)
|
||||
}
|
||||
isDesignMode={true} // 편집 모드로 설정
|
||||
onClick={(e) => handleComponentClick(child, e)}
|
||||
onDoubleClick={(e) => handleComponentDoubleClick(child, e)}
|
||||
onDragStart={(e) => startComponentDrag(child, e)}
|
||||
onDragEnd={endDrag}
|
||||
selectedScreen={selectedScreen}
|
||||
// onZoneComponentDrop 제거
|
||||
onZoneClick={handleZoneClick}
|
||||
// 설정 변경 핸들러 (자식 컴포넌트용)
|
||||
onConfigChange={(config) => {
|
||||
// console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config);
|
||||
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🆕 플로우 버튼 그룹들 */}
|
||||
{Object.entries(buttonGroups).map(([groupId, buttons]) => {
|
||||
if (buttons.length === 0) return null;
|
||||
|
||||
const firstButton = buttons[0];
|
||||
const groupConfig = (firstButton as any).webTypeConfig
|
||||
?.flowVisibilityConfig as FlowVisibilityConfig;
|
||||
|
||||
// 🔧 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
|
||||
const groupPosition = buttons.reduce(
|
||||
(min, button) => ({
|
||||
x: Math.min(min.x, button.position.x),
|
||||
y: Math.min(min.y, button.position.y),
|
||||
z: min.z,
|
||||
}),
|
||||
{ x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 },
|
||||
);
|
||||
|
||||
// 🆕 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
|
||||
const direction = groupConfig.groupDirection || "horizontal";
|
||||
const gap = groupConfig.groupGap ?? 8;
|
||||
|
||||
let groupWidth = 0;
|
||||
let groupHeight = 0;
|
||||
|
||||
if (direction === "horizontal") {
|
||||
// 가로 정렬: 모든 버튼의 너비 + 간격
|
||||
groupWidth = buttons.reduce((total, button, index) => {
|
||||
const buttonWidth = button.size?.width || 100;
|
||||
const gapWidth = index < buttons.length - 1 ? gap : 0;
|
||||
return total + buttonWidth + gapWidth;
|
||||
}, 0);
|
||||
// 세로는 가장 큰 버튼의 높이
|
||||
groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
|
||||
} else {
|
||||
// 세로 정렬: 가로는 가장 큰 버튼의 너비
|
||||
groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
|
||||
// 세로는 모든 버튼의 높이 + 간격
|
||||
groupHeight = buttons.reduce((total, button, index) => {
|
||||
const buttonHeight = button.size?.height || 40;
|
||||
const gapHeight = index < buttons.length - 1 ? gap : 0;
|
||||
return total + buttonHeight + gapHeight;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`flow-button-group-${groupId}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${groupPosition.x}px`,
|
||||
top: `${groupPosition.y}px`,
|
||||
zIndex: groupPosition.z,
|
||||
width: `${groupWidth}px`, // 🆕 명시적 너비
|
||||
height: `${groupHeight}px`, // 🆕 명시적 높이
|
||||
}}
|
||||
>
|
||||
<FlowButtonGroup
|
||||
buttons={buttons}
|
||||
groupConfig={groupConfig}
|
||||
isDesignMode={true}
|
||||
renderButton={(button, isVisible) => {
|
||||
// 드래그 피드백
|
||||
const isDraggingThis =
|
||||
dragState.isDragging && dragState.draggedComponent?.id === button.id;
|
||||
const isBeingDragged =
|
||||
dragState.isDragging &&
|
||||
dragState.draggedComponents.some((dragComp) => dragComp.id === button.id);
|
||||
|
||||
let displayButton = button;
|
||||
|
||||
if (isBeingDragged) {
|
||||
if (isDraggingThis) {
|
||||
displayButton = {
|
||||
...button,
|
||||
position: dragState.currentPosition,
|
||||
style: {
|
||||
...child.style,
|
||||
...button.style,
|
||||
opacity: 0.8,
|
||||
transform: "scale(1.02)",
|
||||
transition: "none",
|
||||
zIndex: 8888,
|
||||
zIndex: 50,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...displayChild,
|
||||
position: {
|
||||
x: displayChild.position.x - component.position.x,
|
||||
y: displayChild.position.y - component.position.y,
|
||||
z: displayChild.position.z || 1,
|
||||
},
|
||||
};
|
||||
// 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리)
|
||||
const relativeButton = {
|
||||
...displayButton,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: displayButton.position.z || 1,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={`${child.id}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => f.objid) || [])}`}
|
||||
component={relativeChildComponent}
|
||||
isSelected={
|
||||
selectedComponent?.id === child.id ||
|
||||
groupState.selectedComponents.includes(child.id)
|
||||
}
|
||||
isDesignMode={true} // 편집 모드로 설정
|
||||
onClick={(e) => handleComponentClick(child, e)}
|
||||
onDoubleClick={(e) => handleComponentDoubleClick(child, e)}
|
||||
onDragStart={(e) => startComponentDrag(child, e)}
|
||||
onDragEnd={endDrag}
|
||||
selectedScreen={selectedScreen}
|
||||
// onZoneComponentDrop 제거
|
||||
onZoneClick={handleZoneClick}
|
||||
// 설정 변경 핸들러 (자식 컴포넌트용)
|
||||
onConfigChange={(config) => {
|
||||
// console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config);
|
||||
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={button.id}
|
||||
style={{
|
||||
position: "relative",
|
||||
opacity: isVisible ? 1 : 0.5,
|
||||
display: "inline-block",
|
||||
width: button.size?.width || 100,
|
||||
height: button.size?.height || 40,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleComponentClick(button, e);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleComponentDoubleClick(button, e);
|
||||
}}
|
||||
className={
|
||||
selectedComponent?.id === button.id ||
|
||||
groupState.selectedComponents.includes(button.id)
|
||||
? "outline outline-2 outline-offset-2 outline-blue-500"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<DynamicComponentRenderer
|
||||
component={relativeButton}
|
||||
isDesignMode={true}
|
||||
formData={{}}
|
||||
onDataflowComplete={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 드래그 선택 영역 */}
|
||||
{selectionDrag.isSelecting && (
|
||||
|
|
@ -4495,6 +4889,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
</div>
|
||||
</div>{" "}
|
||||
{/* 메인 컨테이너 닫기 */}
|
||||
{/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */}
|
||||
<FlowButtonGroupDialog
|
||||
open={groupDialogOpen}
|
||||
onOpenChange={setGroupDialogOpen}
|
||||
buttonCount={groupState.selectedComponents.length}
|
||||
onConfirm={handleGroupConfirm}
|
||||
/>
|
||||
{/* 모달들 */}
|
||||
{/* 메뉴 할당 모달 */}
|
||||
{showMenuAssignmentModal && selectedScreen && (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown, Search, Info, Settings, FileText, Table, Layers } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, Search, Info, Settings, FileText, Table, Layers, Workflow } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentData, ButtonDataflowConfig } from "@/types/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -254,13 +254,31 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
|||
<SelectItem value="form">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>폼 데이터 기반</span>
|
||||
<span>폼 데이터</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table-selection">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<span>테이블 선택 기반</span>
|
||||
<span>테이블 선택 항목</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table-all">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<span>테이블 전체 데이터 🆕</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="flow-selection">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
<span>플로우 선택 항목</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="flow-step-all">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
<span>플로우 스텝 전체 데이터 🆕</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="both">
|
||||
|
|
@ -269,13 +287,22 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
|||
<span>폼 + 테이블 선택</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="all-sources">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>모든 소스 결합 🆕</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
{dataflowConfig.controlDataSource === "form" && "현재 폼의 입력값으로 조건을 체크합니다"}
|
||||
{dataflowConfig.controlDataSource === "table-selection" &&
|
||||
"테이블에서 선택된 항목의 데이터로 조건을 체크합니다"}
|
||||
{dataflowConfig.controlDataSource === "table-selection" && "테이블에서 선택된 항목의 데이터로 조건을 체크합니다"}
|
||||
{dataflowConfig.controlDataSource === "table-all" && "테이블의 모든 데이터(페이징 무관)로 조건을 체크합니다"}
|
||||
{dataflowConfig.controlDataSource === "flow-selection" && "플로우에서 선택된 항목의 데이터로 조건을 체크합니다"}
|
||||
{dataflowConfig.controlDataSource === "flow-step-all" && "현재 선택된 플로우 스텝의 모든 데이터로 조건을 체크합니다"}
|
||||
{dataflowConfig.controlDataSource === "both" && "폼 데이터와 선택된 항목 데이터를 모두 사용합니다"}
|
||||
{dataflowConfig.controlDataSource === "all-sources" && "폼, 테이블 전체, 플로우 등 모든 소스의 데이터를 결합하여 사용합니다"}
|
||||
{!dataflowConfig.controlDataSource && "폼 데이터를 기본으로 사용합니다"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Workflow, Info, CheckCircle, XCircle, Loader2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Workflow, Info, CheckCircle, XCircle, Loader2, ArrowRight, ArrowDown } from "lucide-react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||
import { getFlowById } from "@/lib/api/flow";
|
||||
|
|
@ -57,6 +58,16 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
currentConfig?.layoutBehavior || "auto-compact"
|
||||
);
|
||||
|
||||
// 🆕 그룹 설정 (auto-compact 모드에서만 사용)
|
||||
const [groupId, setGroupId] = useState<string>(currentConfig?.groupId || `group-${Date.now()}`);
|
||||
const [groupDirection, setGroupDirection] = useState<"horizontal" | "vertical">(
|
||||
currentConfig?.groupDirection || "horizontal"
|
||||
);
|
||||
const [groupGap, setGroupGap] = useState<number>(currentConfig?.groupGap ?? 8);
|
||||
const [groupAlign, setGroupAlign] = useState<"start" | "center" | "end" | "space-between" | "space-around">(
|
||||
currentConfig?.groupAlign || "start"
|
||||
);
|
||||
|
||||
// 선택된 플로우의 스텝 목록
|
||||
const [flowSteps, setFlowSteps] = useState<FlowStep[]>([]);
|
||||
const [flowInfo, setFlowInfo] = useState<FlowDefinition | null>(null);
|
||||
|
|
@ -136,8 +147,8 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
loadFlowSteps();
|
||||
}, [selectedFlowComponentId, flowWidgets]);
|
||||
|
||||
// 설정 저장
|
||||
const handleSave = () => {
|
||||
// 🆕 설정 자동 저장 (즉시 적용) - 오버라이드 가능한 파라미터 지원
|
||||
const applyConfig = (overrides?: Partial<FlowVisibilityConfig>) => {
|
||||
const config: FlowVisibilityConfig = {
|
||||
enabled,
|
||||
targetFlowComponentId: selectedFlowComponentId || "",
|
||||
|
|
@ -147,49 +158,79 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
visibleSteps: mode === "whitelist" ? visibleSteps : undefined,
|
||||
hiddenSteps: mode === "blacklist" ? hiddenSteps : undefined,
|
||||
layoutBehavior,
|
||||
// 🆕 그룹 설정 (auto-compact 모드일 때만)
|
||||
...(layoutBehavior === "auto-compact" && {
|
||||
groupId,
|
||||
groupDirection,
|
||||
groupGap,
|
||||
groupAlign,
|
||||
}),
|
||||
// 오버라이드 적용
|
||||
...overrides,
|
||||
};
|
||||
|
||||
console.log("💾 [FlowVisibilityConfig] 설정 자동 저장:", {
|
||||
componentId: component.id,
|
||||
config,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
onUpdateProperty("webTypeConfig.flowVisibilityConfig", config);
|
||||
toast.success("플로우 단계별 표시 설정이 저장되었습니다");
|
||||
};
|
||||
|
||||
// 체크박스 토글
|
||||
const toggleStep = (stepId: number) => {
|
||||
if (mode === "whitelist") {
|
||||
setVisibleSteps((prev) =>
|
||||
prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId]
|
||||
);
|
||||
const newSteps = visibleSteps.includes(stepId)
|
||||
? visibleSteps.filter((id) => id !== stepId)
|
||||
: [...visibleSteps, stepId];
|
||||
setVisibleSteps(newSteps);
|
||||
// 🆕 새 상태값을 직접 전달하여 즉시 저장
|
||||
applyConfig({ visibleSteps: newSteps });
|
||||
} else if (mode === "blacklist") {
|
||||
setHiddenSteps((prev) =>
|
||||
prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId]
|
||||
);
|
||||
const newSteps = hiddenSteps.includes(stepId)
|
||||
? hiddenSteps.filter((id) => id !== stepId)
|
||||
: [...hiddenSteps, stepId];
|
||||
setHiddenSteps(newSteps);
|
||||
// 🆕 새 상태값을 직접 전달하여 즉시 저장
|
||||
applyConfig({ hiddenSteps: newSteps });
|
||||
}
|
||||
};
|
||||
|
||||
// 빠른 선택
|
||||
const selectAll = () => {
|
||||
if (mode === "whitelist") {
|
||||
setVisibleSteps(flowSteps.map((s) => s.id));
|
||||
const newSteps = flowSteps.map((s) => s.id);
|
||||
setVisibleSteps(newSteps);
|
||||
applyConfig({ visibleSteps: newSteps });
|
||||
} else if (mode === "blacklist") {
|
||||
setHiddenSteps([]);
|
||||
applyConfig({ hiddenSteps: [] });
|
||||
}
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
if (mode === "whitelist") {
|
||||
setVisibleSteps([]);
|
||||
applyConfig({ visibleSteps: [] });
|
||||
} else if (mode === "blacklist") {
|
||||
setHiddenSteps(flowSteps.map((s) => s.id));
|
||||
const newSteps = flowSteps.map((s) => s.id);
|
||||
setHiddenSteps(newSteps);
|
||||
applyConfig({ hiddenSteps: newSteps });
|
||||
}
|
||||
};
|
||||
|
||||
const invertSelection = () => {
|
||||
if (mode === "whitelist") {
|
||||
const allStepIds = flowSteps.map((s) => s.id);
|
||||
setVisibleSteps(allStepIds.filter((id) => !visibleSteps.includes(id)));
|
||||
const newSteps = allStepIds.filter((id) => !visibleSteps.includes(id));
|
||||
setVisibleSteps(newSteps);
|
||||
applyConfig({ visibleSteps: newSteps });
|
||||
} else if (mode === "blacklist") {
|
||||
const allStepIds = flowSteps.map((s) => s.id);
|
||||
setHiddenSteps(allStepIds.filter((id) => !hiddenSteps.includes(id)));
|
||||
const newSteps = allStepIds.filter((id) => !hiddenSteps.includes(id));
|
||||
setHiddenSteps(newSteps);
|
||||
applyConfig({ hiddenSteps: newSteps });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -208,7 +249,14 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
<CardContent className="space-y-4">
|
||||
{/* 활성화 체크박스 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="flow-control-enabled" checked={enabled} onCheckedChange={(checked) => setEnabled(!!checked)} />
|
||||
<Checkbox
|
||||
id="flow-control-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnabled(!!checked);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="flow-control-enabled" className="text-sm font-medium">
|
||||
플로우 단계에 따라 버튼 표시 제어
|
||||
</Label>
|
||||
|
|
@ -219,7 +267,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
{/* 대상 플로우 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">대상 플로우</Label>
|
||||
<Select value={selectedFlowComponentId || ""} onValueChange={setSelectedFlowComponentId}>
|
||||
<Select
|
||||
value={selectedFlowComponentId || ""}
|
||||
onValueChange={(value) => {
|
||||
setSelectedFlowComponentId(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="플로우 위젯 선택" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -243,7 +297,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
{/* 모드 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">표시 모드</Label>
|
||||
<RadioGroup value={mode} onValueChange={(value: any) => setMode(value)}>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value: any) => {
|
||||
setMode(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="whitelist" id="mode-whitelist" />
|
||||
<Label htmlFor="mode-whitelist" className="text-sm font-normal">
|
||||
|
|
@ -319,7 +379,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
{/* 레이아웃 옵션 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">레이아웃 동작</Label>
|
||||
<RadioGroup value={layoutBehavior} onValueChange={(value: any) => setLayoutBehavior(value)}>
|
||||
<RadioGroup
|
||||
value={layoutBehavior}
|
||||
onValueChange={(value: any) => {
|
||||
setLayoutBehavior(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="preserve-position" id="layout-preserve" />
|
||||
<Label htmlFor="layout-preserve" className="text-sm font-normal">
|
||||
|
|
@ -335,6 +401,113 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 🆕 그룹 설정 (auto-compact 모드일 때만 표시) */}
|
||||
{layoutBehavior === "auto-compact" && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
그룹 설정
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
같은 그룹 ID를 가진 버튼들이 자동으로 정렬됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹 ID */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-id" className="text-sm font-medium">
|
||||
그룹 ID
|
||||
</Label>
|
||||
<Input
|
||||
id="group-id"
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value)}
|
||||
placeholder="group-1"
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
같은 그룹 ID를 가진 버튼들이 하나의 그룹으로 묶입니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">정렬 방향</Label>
|
||||
<RadioGroup
|
||||
value={groupDirection}
|
||||
onValueChange={(value: any) => {
|
||||
setGroupDirection(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="horizontal" id="direction-horizontal" />
|
||||
<Label htmlFor="direction-horizontal" className="flex items-center gap-2 text-sm font-normal">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
가로 정렬
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="vertical" id="direction-vertical" />
|
||||
<Label htmlFor="direction-vertical" className="flex items-center gap-2 text-sm font-normal">
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
세로 정렬
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 버튼 간격 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-gap" className="text-sm font-medium">
|
||||
버튼 간격 (px)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="group-gap"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={groupGap}
|
||||
onChange={(e) => {
|
||||
setGroupGap(Number(e.target.value));
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{groupGap}px
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-align" className="text-sm font-medium">
|
||||
정렬 방식
|
||||
</Label>
|
||||
<Select
|
||||
value={groupAlign}
|
||||
onValueChange={(value: any) => {
|
||||
setGroupAlign(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="group-align" className="h-8 text-xs sm:h-9 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="start">시작점 정렬</SelectItem>
|
||||
<SelectItem value="center">중앙 정렬</SelectItem>
|
||||
<SelectItem value="end">끝점 정렬</SelectItem>
|
||||
<SelectItem value="space-between">양 끝 정렬</SelectItem>
|
||||
<SelectItem value="space-around">균등 배분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 미리보기 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
|
|
@ -374,10 +547,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<Button onClick={handleSave} className="w-full">
|
||||
설정 저장
|
||||
</Button>
|
||||
{/* 🆕 자동 저장 안내 */}
|
||||
<Alert className="border-green-200 bg-green-50">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-xs text-green-800">
|
||||
설정이 자동으로 저장됩니다. 화면 저장 시 함께 적용됩니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowRight, ArrowDown } from "lucide-react";
|
||||
|
||||
interface FlowButtonGroupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
buttonCount: number;
|
||||
onConfirm: (settings: {
|
||||
direction: "horizontal" | "vertical";
|
||||
gap: number;
|
||||
align: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const FlowButtonGroupDialog: React.FC<FlowButtonGroupDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
buttonCount,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [direction, setDirection] = useState<"horizontal" | "vertical">("horizontal");
|
||||
const [gap, setGap] = useState<number>(8);
|
||||
const [align, setAlign] = useState<"start" | "center" | "end" | "space-between" | "space-around">("start");
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm({ direction, gap, align });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">플로우 버튼 그룹 생성</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{buttonCount}개의 버튼을 하나의 그룹으로 묶습니다. 자동 정렬 설정을 지정하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 정렬 방향 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">정렬 방향</Label>
|
||||
<RadioGroup value={direction} onValueChange={(value: any) => setDirection(value)}>
|
||||
<div className="flex items-center space-x-3">
|
||||
<RadioGroupItem value="horizontal" id="direction-horizontal" />
|
||||
<Label htmlFor="direction-horizontal" className="flex items-center gap-2 text-sm font-normal">
|
||||
<ArrowRight className="h-4 w-4 text-blue-600" />
|
||||
<span>가로 정렬</span>
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
← →
|
||||
</Badge>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<RadioGroupItem value="vertical" id="direction-vertical" />
|
||||
<Label htmlFor="direction-vertical" className="flex items-center gap-2 text-sm font-normal">
|
||||
<ArrowDown className="h-4 w-4 text-blue-600" />
|
||||
<span>세로 정렬</span>
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
↑ ↓
|
||||
</Badge>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 버튼 간격 */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="gap" className="text-sm font-medium">
|
||||
버튼 간격 (px)
|
||||
</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="gap"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={gap}
|
||||
onChange={(e) => setGap(Number(e.target.value))}
|
||||
className="h-9 text-sm sm:h-10"
|
||||
/>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{gap}px
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground sm:text-xs">버튼 사이의 간격을 설정합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방식 */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="align" className="text-sm font-medium">
|
||||
정렬 방식
|
||||
</Label>
|
||||
<Select value={align} onValueChange={(value: any) => setAlign(value)}>
|
||||
<SelectTrigger id="align" className="h-9 text-sm sm:h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="start">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>시작점 정렬</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{direction === "horizontal" ? "← 왼쪽" : "↑ 위"}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>중앙 정렬</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
↔ 가운데
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>끝점 정렬</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{direction === "horizontal" ? "→ 오른쪽" : "↓ 아래"}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="space-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>양 끝 정렬</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
↔ 양끝
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="space-around">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>균등 배분</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
↔ 균등
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
버튼들이 그룹 내에서 어떻게 배치될지 결정합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="mb-3 text-xs font-medium text-blue-900">설정 미리보기</p>
|
||||
<div className="space-y-2 text-xs text-blue-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{direction === "horizontal" ? <ArrowRight className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />}
|
||||
<span>
|
||||
{direction === "horizontal" ? "가로" : "세로"} 방향으로 {gap}px 간격
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>•</span>
|
||||
<span>
|
||||
{align === "start" && "시작점"}
|
||||
{align === "center" && "중앙"}
|
||||
{align === "end" && "끝점"}
|
||||
{align === "space-between" && "양 끝"}
|
||||
{align === "space-around" && "균등"} 정렬
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-9 flex-1 text-sm sm:h-10 sm:flex-none"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} className="h-9 flex-1 text-sm sm:h-10 sm:flex-none">
|
||||
그룹 생성
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Layers, Trash2, ArrowRight, ArrowDown, Info } from "lucide-react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { findAllButtonGroups, getButtonGroupInfo, ButtonGroupInfo } from "@/lib/utils/flowButtonGroupUtils";
|
||||
|
||||
interface FlowButtonGroupPanelProps {
|
||||
components: ComponentData[];
|
||||
onSelectGroup: (buttonIds: string[]) => void;
|
||||
onDeleteGroup: (groupId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlowButtonGroupPanel
|
||||
*
|
||||
* 화면의 모든 플로우 버튼 그룹을 관리하는 패널
|
||||
* - 그룹 목록 표시
|
||||
* - 그룹 선택 (해당 그룹의 버튼들 선택)
|
||||
* - 그룹 삭제
|
||||
*/
|
||||
export const FlowButtonGroupPanel: React.FC<FlowButtonGroupPanelProps> = ({
|
||||
components,
|
||||
onSelectGroup,
|
||||
onDeleteGroup,
|
||||
}) => {
|
||||
// 모든 버튼 그룹 찾기
|
||||
const buttonGroups = useMemo(() => findAllButtonGroups(components), [components]);
|
||||
|
||||
// 그룹 정보 배열
|
||||
const groupInfos = useMemo(() => {
|
||||
return Object.entries(buttonGroups)
|
||||
.map(([groupId, buttons]) => getButtonGroupInfo(groupId, buttons))
|
||||
.filter((info): info is ButtonGroupInfo => info !== null);
|
||||
}, [buttonGroups]);
|
||||
|
||||
// 그룹이 없을 때
|
||||
if (groupInfos.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Layers className="h-5 w-5" />
|
||||
플로우 버튼 그룹
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">화면에 생성된 플로우 버튼 그룹이 없습니다</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
<p className="mb-2">플로우 버튼 그룹을 만들려면:</p>
|
||||
<ol className="ml-4 list-decimal space-y-1 text-[11px]">
|
||||
<li>2개 이상의 버튼을 선택하세요 (Shift + 클릭)</li>
|
||||
<li>우측 하단의 "플로우 그룹 생성" 버튼을 클릭하세요</li>
|
||||
<li>같은 그룹의 버튼들이 자동으로 정렬됩니다</li>
|
||||
</ol>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Layers className="h-5 w-5" />
|
||||
플로우 버튼 그룹
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{groupInfos.length}개
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">화면의 플로우 버튼 그룹 관리</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{groupInfos.map((groupInfo, index) => (
|
||||
<div key={groupInfo.groupId}>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 space-y-2">
|
||||
{/* 그룹 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
그룹 #{index + 1}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{groupInfo.buttonCount}개 버튼
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onSelectGroup(groupInfo.buttons.map((b) => b.id))}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDeleteGroup(groupInfo.groupId)}
|
||||
className="h-7 px-2 text-xs text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹 설정 정보 */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<div className="flex items-center gap-1">
|
||||
{groupInfo.direction === "horizontal" ? (
|
||||
<ArrowRight className="h-3 w-3 text-blue-600" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3 text-blue-600" />
|
||||
)}
|
||||
<span className="text-gray-600">
|
||||
{groupInfo.direction === "horizontal" ? "가로" : "세로"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-gray-600">간격 {groupInfo.gap}px</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-gray-600">
|
||||
{groupInfo.align === "start" && "시작점"}
|
||||
{groupInfo.align === "center" && "중앙"}
|
||||
{groupInfo.align === "end" && "끝점"}
|
||||
{groupInfo.align === "space-between" && "양끝"}
|
||||
{groupInfo.align === "space-around" && "균등배분"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 그룹 ID (디버깅용) */}
|
||||
<details className="text-[10px]">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
|
||||
그룹 ID 보기
|
||||
</summary>
|
||||
<code className="mt-1 block rounded bg-gray-200 px-2 py-1 text-[9px]">
|
||||
{groupInfo.groupId}
|
||||
</code>
|
||||
</details>
|
||||
|
||||
{/* 버튼 목록 */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{groupInfo.buttons.map((button) => (
|
||||
<div
|
||||
key={button.id}
|
||||
className="flex items-center gap-2 rounded bg-white px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{button.label || button.text || "버튼"}
|
||||
</span>
|
||||
<code className="text-[10px] text-gray-400">{button.id.slice(-8)}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{index < groupInfos.length - 1 && <Separator className="my-3" />}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ export interface ToolbarButton {
|
|||
|
||||
interface LeftUnifiedToolbarProps {
|
||||
buttons: ToolbarButton[];
|
||||
panelStates: Record<string, { isOpen: boolean }>;
|
||||
panelStates: Record<string, { isOpen: boolean; badge?: number }>;
|
||||
onTogglePanel: (panelId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons,
|
|||
|
||||
const renderButton = (button: ToolbarButton) => {
|
||||
const isActive = panelStates[button.id]?.isOpen || false;
|
||||
const badge = panelStates[button.id]?.badge;
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
@ -45,6 +46,11 @@ export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons,
|
|||
<div className="relative">
|
||||
{button.icon}
|
||||
{isActive && <div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-white" />}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<div className="absolute -top-2 -right-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white shadow-md">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium">{button.label}</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||
import { useCurrentFlowStep } from "@/stores/flowStepStore";
|
||||
|
||||
interface FlowButtonGroupProps {
|
||||
/**
|
||||
* 그룹에 속한 버튼 컴포넌트들
|
||||
*/
|
||||
buttons: ComponentData[];
|
||||
|
||||
/**
|
||||
* 그룹 설정 (첫 번째 버튼의 설정 사용)
|
||||
*/
|
||||
groupConfig: FlowVisibilityConfig;
|
||||
|
||||
/**
|
||||
* 버튼 렌더링 함수
|
||||
*/
|
||||
renderButton: (button: ComponentData, isVisible: boolean) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* 디자인 모드 여부
|
||||
*/
|
||||
isDesignMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlowButtonGroup 컴포넌트
|
||||
*
|
||||
* 플로우 단계별로 버튼을 표시/숨기고, auto-compact 모드일 때
|
||||
* Flexbox로 자동 정렬하는 버튼 그룹 컨테이너입니다.
|
||||
*
|
||||
* **특징:**
|
||||
* - 같은 groupId를 가진 버튼들을 하나의 Flexbox 컨테이너로 묶음
|
||||
* - 현재 플로우 단계에 따라 버튼을 동적으로 표시/숨김
|
||||
* - 숨겨진 버튼은 렌더링하지 않아 빈 공간이 자동으로 제거됨
|
||||
* - 그룹 내 정렬, 간격, 방향을 세밀하게 제어 가능
|
||||
*/
|
||||
export const FlowButtonGroup: React.FC<FlowButtonGroupProps> = ({
|
||||
buttons,
|
||||
groupConfig,
|
||||
renderButton,
|
||||
isDesignMode = false,
|
||||
}) => {
|
||||
// 현재 플로우 단계
|
||||
const currentStep = useCurrentFlowStep(groupConfig.targetFlowComponentId);
|
||||
|
||||
// 각 버튼의 표시 여부 계산
|
||||
const buttonVisibility = useMemo(() => {
|
||||
return buttons.map((button) => {
|
||||
const config = (button as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
|
||||
|
||||
// 플로우 제어 비활성화 시 항상 표시
|
||||
if (!config?.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 플로우 단계가 선택되지 않은 경우
|
||||
if (currentStep === null) {
|
||||
// 화이트리스트 모드일 때는 단계 미선택 시 숨김
|
||||
if (config.mode === "whitelist") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const { mode, visibleSteps = [], hiddenSteps = [] } = config;
|
||||
|
||||
if (mode === "whitelist") {
|
||||
return visibleSteps.includes(currentStep);
|
||||
} else if (mode === "blacklist") {
|
||||
return !hiddenSteps.includes(currentStep);
|
||||
} else if (mode === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [buttons, currentStep]);
|
||||
|
||||
// 표시할 버튼 필터링
|
||||
const visibleButtons = useMemo(() => {
|
||||
return buttons.filter((_, index) => buttonVisibility[index]);
|
||||
}, [buttons, buttonVisibility]);
|
||||
|
||||
// 그룹 스타일 계산
|
||||
const groupStyle: React.CSSProperties = useMemo(() => {
|
||||
const direction = groupConfig.groupDirection || "horizontal";
|
||||
const gap = groupConfig.groupGap ?? 8;
|
||||
const align = groupConfig.groupAlign || "start";
|
||||
|
||||
let justifyContent: string;
|
||||
switch (align) {
|
||||
case "start":
|
||||
justifyContent = "flex-start";
|
||||
break;
|
||||
case "center":
|
||||
justifyContent = "center";
|
||||
break;
|
||||
case "end":
|
||||
justifyContent = "flex-end";
|
||||
break;
|
||||
case "space-between":
|
||||
justifyContent = "space-between";
|
||||
break;
|
||||
case "space-around":
|
||||
justifyContent = "space-around";
|
||||
break;
|
||||
default:
|
||||
justifyContent = "flex-start";
|
||||
}
|
||||
|
||||
return {
|
||||
display: "flex",
|
||||
flexDirection: direction === "vertical" ? "column" : "row",
|
||||
gap: `${gap}px`,
|
||||
justifyContent,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap", // 넘칠 경우 줄바꿈
|
||||
width: "100%", // 🆕 전체 너비를 차지하도록 설정 (끝점/중앙 정렬을 위해 필수)
|
||||
};
|
||||
}, [groupConfig]);
|
||||
|
||||
// 디자인 모드에서는 모든 버튼 표시 (반투명 처리)
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div style={groupStyle} className="flow-button-group">
|
||||
{buttons.map((button, index) => (
|
||||
<div
|
||||
key={button.id}
|
||||
style={{
|
||||
opacity: buttonVisibility[index] ? 1 : 0.3,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{renderButton(button, buttonVisibility[index])}
|
||||
{!buttonVisibility[index] && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-gray-900/10"
|
||||
style={{
|
||||
border: "1px dashed #94a3b8",
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500">숨김</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 뷰 모드: 보이는 버튼만 렌더링 (auto-compact 동작)
|
||||
return (
|
||||
<div style={groupStyle} className="flow-button-group">
|
||||
{visibleButtons.map((button) => (
|
||||
<div key={button.id} style={{ position: "relative" }}>
|
||||
{renderButton(button, true)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -4,13 +4,12 @@ import React, { useEffect, useState } from "react";
|
|||
import { FlowComponent } from "@/types/screen-management";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, Loader2, ChevronDown, ChevronUp, History } from "lucide-react";
|
||||
import { getFlowById, getAllStepCounts, getStepDataList, moveBatchData, getFlowAuditLogs } from "@/lib/api/flow";
|
||||
import { AlertCircle, Loader2, ChevronUp, History } from "lucide-react";
|
||||
import { getFlowById, getAllStepCounts, getStepDataList, getFlowAuditLogs } from "@/lib/api/flow";
|
||||
import type { FlowDefinition, FlowStep, FlowAuditLog } from "@/types/flow";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -37,7 +36,13 @@ interface FlowWidgetProps {
|
|||
onFlowRefresh?: () => void; // 새로고침 완료 콜백
|
||||
}
|
||||
|
||||
export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowRefreshKey, onFlowRefresh }: FlowWidgetProps) {
|
||||
export function FlowWidget({
|
||||
component,
|
||||
onStepClick,
|
||||
onSelectedDataChange,
|
||||
flowRefreshKey,
|
||||
onFlowRefresh,
|
||||
}: FlowWidgetProps) {
|
||||
// 🆕 전역 상태 관리
|
||||
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
||||
const resetFlow = useFlowStepStore((state) => state.resetFlow);
|
||||
|
|
@ -55,8 +60,10 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
const [stepDataColumns, setStepDataColumns] = useState<string[]>([]);
|
||||
const [stepDataLoading, setStepDataLoading] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [movingData, setMovingData] = useState(false);
|
||||
const [selectedNextStepId, setSelectedNextStepId] = useState<number | null>(null); // 선택된 다음 단계
|
||||
|
||||
// 🆕 스텝 데이터 페이지네이션 상태
|
||||
const [stepDataPage, setStepDataPage] = useState(1);
|
||||
const [stepDataPageSize] = useState(20);
|
||||
|
||||
// 오딧 로그 상태
|
||||
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
|
||||
|
|
@ -76,7 +83,6 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
// 🆕 플로우 컴포넌트 ID (버튼이 이 플로우를 참조할 때 사용)
|
||||
const flowComponentId = component.id;
|
||||
|
||||
|
||||
// 선택된 스텝의 데이터를 다시 로드하는 함수
|
||||
const refreshStepData = async () => {
|
||||
if (!flowId) return;
|
||||
|
|
@ -85,7 +91,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
// 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이)
|
||||
const countsResponse = await getAllStepCounts(flowId);
|
||||
console.log("📊 스텝 카운트 API 응답:", countsResponse);
|
||||
|
||||
|
||||
if (countsResponse.success && countsResponse.data) {
|
||||
// Record 형태로 변환
|
||||
const countsMap: Record<number, number> = {};
|
||||
|
|
@ -93,10 +99,10 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
countsResponse.data.forEach((item: any) => {
|
||||
countsMap[item.stepId] = item.count;
|
||||
});
|
||||
} else if (typeof countsResponse.data === 'object') {
|
||||
} else if (typeof countsResponse.data === "object") {
|
||||
Object.assign(countsMap, countsResponse.data);
|
||||
}
|
||||
|
||||
|
||||
console.log("✅ 스텝 카운트 업데이트:", countsMap);
|
||||
setStepCounts(countsMap);
|
||||
}
|
||||
|
|
@ -104,7 +110,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
// 선택된 스텝이 있으면 해당 스텝의 데이터도 새로고침
|
||||
if (selectedStepId) {
|
||||
setStepDataLoading(true);
|
||||
|
||||
|
||||
const response = await getStepDataList(flowId, selectedStepId, 1, 100);
|
||||
|
||||
if (!response.success) {
|
||||
|
|
@ -227,6 +233,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
setStepData([]);
|
||||
setStepDataColumns([]);
|
||||
setSelectedRows(new Set());
|
||||
setStepDataPage(1); // 🆕 페이지 리셋
|
||||
onSelectedDataChange?.([], null);
|
||||
|
||||
console.log("🔄 [FlowWidget] 단계 선택 해제:", { flowComponentId, stepId });
|
||||
|
|
@ -238,6 +245,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
setSelectedStep(flowComponentId, stepId); // 🆕 전역 상태 업데이트
|
||||
setStepDataLoading(true);
|
||||
setSelectedRows(new Set());
|
||||
setStepDataPage(1); // 🆕 페이지 리셋
|
||||
onSelectedDataChange?.([], stepId);
|
||||
|
||||
console.log("✅ [FlowWidget] 단계 선택:", { flowComponentId, stepId, stepName });
|
||||
|
|
@ -275,7 +283,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
newSelected.add(rowIndex);
|
||||
}
|
||||
setSelectedRows(newSelected);
|
||||
|
||||
|
||||
// 선택된 데이터를 상위로 전달
|
||||
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
|
||||
console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", {
|
||||
|
|
@ -297,91 +305,12 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
newSelected = new Set(stepData.map((_, index) => index));
|
||||
}
|
||||
setSelectedRows(newSelected);
|
||||
|
||||
|
||||
// 선택된 데이터를 상위로 전달
|
||||
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
|
||||
onSelectedDataChange?.(selectedData, selectedStepId);
|
||||
};
|
||||
|
||||
// 현재 단계에서 가능한 다음 단계들 찾기
|
||||
const getNextSteps = (currentStepId: number) => {
|
||||
return connections
|
||||
.filter((conn) => conn.fromStepId === currentStepId)
|
||||
.map((conn) => steps.find((s) => s.id === conn.toStepId))
|
||||
.filter((step) => step !== undefined);
|
||||
};
|
||||
|
||||
// 다음 단계로 이동
|
||||
const handleMoveToNext = async (targetStepId?: number) => {
|
||||
if (!flowId || !selectedStepId || selectedRows.size === 0) return;
|
||||
|
||||
// 다음 단계 결정
|
||||
let nextStepId = targetStepId || selectedNextStepId;
|
||||
|
||||
if (!nextStepId) {
|
||||
const nextSteps = getNextSteps(selectedStepId);
|
||||
if (nextSteps.length === 0) {
|
||||
toast.error("다음 단계가 없습니다");
|
||||
return;
|
||||
}
|
||||
if (nextSteps.length === 1) {
|
||||
nextStepId = nextSteps[0].id;
|
||||
} else {
|
||||
toast.error("다음 단계를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedData = Array.from(selectedRows).map((index) => stepData[index]);
|
||||
|
||||
try {
|
||||
setMovingData(true);
|
||||
|
||||
// Primary Key 컬럼 추출 (첫 번째 컬럼 가정)
|
||||
const primaryKeyColumn = stepDataColumns[0];
|
||||
const dataIds = selectedData.map((data) => String(data[primaryKeyColumn]));
|
||||
|
||||
// 배치 이동 API 호출
|
||||
const response = await moveBatchData({
|
||||
flowId,
|
||||
fromStepId: selectedStepId,
|
||||
toStepId: nextStepId,
|
||||
dataIds,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || "데이터 이동에 실패했습니다");
|
||||
}
|
||||
|
||||
const nextStepName = steps.find((s) => s.id === nextStepId)?.stepName;
|
||||
toast.success(`${selectedRows.size}건의 데이터를 "${nextStepName}"(으)로 이동했습니다`);
|
||||
|
||||
// 선택 초기화
|
||||
setSelectedNextStepId(null);
|
||||
setSelectedRows(new Set());
|
||||
// 선택 초기화 전달
|
||||
onSelectedDataChange?.([], selectedStepId);
|
||||
|
||||
// 데이터 새로고침
|
||||
await handleStepClick(selectedStepId, steps.find((s) => s.id === selectedStepId)?.stepName || "");
|
||||
|
||||
// 건수 새로고침
|
||||
const countsResponse = await getAllStepCounts(flowId);
|
||||
if (countsResponse.success && countsResponse.data) {
|
||||
const countsMap: Record<number, number> = {};
|
||||
countsResponse.data.forEach((item: any) => {
|
||||
countsMap[item.stepId] = item.count;
|
||||
});
|
||||
setStepCounts(countsMap);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to move data:", err);
|
||||
toast.error(err.message || "데이터 이동 중 오류가 발생했습니다");
|
||||
} finally {
|
||||
setMovingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 오딧 로그 로드
|
||||
const loadAuditLogs = async () => {
|
||||
if (!flowId) return;
|
||||
|
|
@ -411,6 +340,10 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
const paginatedAuditLogs = auditLogs.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize);
|
||||
const totalAuditPages = Math.ceil(auditLogs.length / auditPageSize);
|
||||
|
||||
// 🆕 페이지네이션된 스텝 데이터
|
||||
const paginatedStepData = stepData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize);
|
||||
const totalStepDataPages = Math.ceil(stepData.length / stepDataPageSize);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
|
|
@ -452,9 +385,9 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
: "flex flex-col items-center gap-4";
|
||||
|
||||
return (
|
||||
<div className="@container min-h-full w-full p-2 sm:p-4 lg:p-6">
|
||||
<div className="@container flex h-full w-full flex-col p-2 sm:p-4 lg:p-6">
|
||||
{/* 플로우 제목 */}
|
||||
<div className="mb-3 sm:mb-4">
|
||||
<div className="mb-3 flex-shrink-0 sm:mb-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
|
||||
|
||||
|
|
@ -647,7 +580,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
</div>
|
||||
|
||||
{/* 플로우 스텝 목록 */}
|
||||
<div className={containerClass}>
|
||||
<div className={`${containerClass} flex-shrink-0`}>
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
{/* 스텝 카드 */}
|
||||
|
|
@ -714,207 +647,212 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
|
||||
{/* 선택된 스텝의 데이터 리스트 */}
|
||||
{selectedStepId !== null && (
|
||||
<div className="bg-muted/30 mt-4 w-full rounded-lg p-4 sm:mt-6 sm:rounded-xl sm:p-5 lg:mt-8 lg:p-6">
|
||||
<div className="bg-muted/30 mt-4 flex min-h-0 w-full flex-1 flex-col rounded-lg border sm:mt-6 lg:mt-8">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex flex-col items-start justify-between gap-3 sm:mb-6 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-foreground text-base font-semibold sm:text-lg">
|
||||
{steps.find((s) => s.id === selectedStepId)?.stepName}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">총 {stepData.length}건의 데이터</p>
|
||||
</div>
|
||||
{allowDataMove &&
|
||||
selectedRows.size > 0 &&
|
||||
(() => {
|
||||
const nextSteps = getNextSteps(selectedStepId);
|
||||
return nextSteps.length > 1 ? (
|
||||
// 다음 단계가 여러 개인 경우: 선택 UI 표시
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<Select
|
||||
value={selectedNextStepId?.toString() || ""}
|
||||
onValueChange={(value) => setSelectedNextStepId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:w-[180px] sm:text-sm">
|
||||
<SelectValue placeholder="이동할 단계 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nextSteps.map((step) => (
|
||||
<SelectItem key={step.id} value={step.id.toString()}>
|
||||
{step.stepName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={() => handleMoveToNext()}
|
||||
disabled={movingData || !selectedNextStepId}
|
||||
className="h-8 gap-1 px-3 text-xs sm:h-10 sm:gap-2 sm:px-4 sm:text-sm"
|
||||
>
|
||||
{movingData ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||
<span>이동 중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
<span>이동 ({selectedRows.size})</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// 다음 단계가 하나인 경우: 바로 이동 버튼만 표시
|
||||
<Button
|
||||
onClick={() => handleMoveToNext()}
|
||||
disabled={movingData}
|
||||
className="h-8 w-full gap-1 px-3 text-xs sm:h-10 sm:w-auto sm:gap-2 sm:px-4 sm:text-sm"
|
||||
>
|
||||
{movingData ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||
<span className="hidden sm:inline">이동 중...</span>
|
||||
<span className="sm:hidden">이동중</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">
|
||||
{nextSteps.length > 0 ? `${nextSteps[0].stepName}(으)로 이동` : "다음 단계로 이동"} (
|
||||
{selectedRows.size})
|
||||
</span>
|
||||
<span className="sm:hidden">다음 ({selectedRows.size})</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h4 className="text-foreground text-base font-semibold sm:text-lg">
|
||||
{steps.find((s) => s.id === selectedStepId)?.stepName}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
|
||||
총 {stepData.length}건의 데이터
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="text-primary ml-2 font-medium">({selectedRows.size}건 선택됨)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
{stepDataLoading ? (
|
||||
<div className="flex items-center justify-center py-8 sm:py-12">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
|
||||
<span className="text-muted-foreground ml-2 text-xs sm:ml-3 sm:text-sm">데이터 로딩 중...</span>
|
||||
</div>
|
||||
) : stepData.length === 0 ? (
|
||||
<div className="bg-card flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8 sm:py-12">
|
||||
<svg
|
||||
className="text-muted-foreground/50 mb-2 h-10 w-10 sm:mb-3 sm:h-12 sm:w-12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">데이터가 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 모바일: 카드 뷰 (컨테이너 640px 미만) */}
|
||||
<div className="space-y-3 @sm:hidden">
|
||||
{stepData.map((row, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-card rounded-lg border p-3 transition-colors ${
|
||||
selectedRows.has(index) ? "border-primary bg-primary/5" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 체크박스 헤더 */}
|
||||
{allowDataMove && (
|
||||
<div className="mb-2 flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">선택</span>
|
||||
<Checkbox checked={selectedRows.has(index)} onCheckedChange={() => toggleRowSelection(index)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 필드들 */}
|
||||
<div className="space-y-2">
|
||||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="flex justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">{col}:</span>
|
||||
<span className="text-foreground truncate text-xs">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 데이터 영역 - 스크롤 가능 */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{stepDataLoading ? (
|
||||
<div className="flex h-full items-center justify-center py-12">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">데이터 로딩 중...</span>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱: 테이블 뷰 (컨테이너 640px 이상) */}
|
||||
<div className="bg-card hidden overflow-x-auto rounded-lg border shadow-sm @sm:block">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{allowDataMove && (
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
||||
onCheckedChange={toggleAllRows}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableHead key={col} className="text-xs font-semibold whitespace-nowrap sm:text-sm">
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stepData.map((row, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
className={`transition-colors ${selectedRows.has(index) ? "bg-primary/5" : "hover:bg-muted/50"}`}
|
||||
) : stepData.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center py-12">
|
||||
<svg
|
||||
className="text-muted-foreground/50 mb-3 h-12 w-12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground text-sm">데이터가 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 모바일: 카드 뷰 */}
|
||||
<div className="space-y-2 p-3 @sm:hidden">
|
||||
{paginatedStepData.map((row, pageIndex) => {
|
||||
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
||||
return (
|
||||
<div
|
||||
key={actualIndex}
|
||||
className={`bg-card rounded-md border p-3 transition-colors ${
|
||||
selectedRows.has(actualIndex) ? "bg-primary/5 border-primary/30" : ""
|
||||
}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
<TableCell className="w-12">
|
||||
<div className="mb-2 flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">선택</span>
|
||||
<Checkbox
|
||||
checked={selectedRows.has(index)}
|
||||
onCheckedChange={() => toggleRowSelection(index)}
|
||||
checked={selectedRows.has(actualIndex)}
|
||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground font-medium">{col}:</span>
|
||||
<span className="text-foreground truncate">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 데스크톱: 테이블 뷰 */}
|
||||
<div className="hidden @sm:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
{allowDataMove && (
|
||||
<TableHead className="bg-muted/50 sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-sm">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
||||
onCheckedChange={toggleAllRows}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="font-mono text-xs whitespace-nowrap sm:text-sm">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableHead
|
||||
key={col}
|
||||
className="bg-muted/50 sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap sm:text-sm"
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedStepData.map((row, pageIndex) => {
|
||||
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
||||
return (
|
||||
<TableRow
|
||||
key={actualIndex}
|
||||
className={`hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(actualIndex)}
|
||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="border-b px-3 py-2 text-xs whitespace-nowrap sm:text-sm">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 푸터 */}
|
||||
{!stepDataLoading && stepData.length > 0 && totalStepDataPages > 1 && (
|
||||
<div className="bg-background flex-shrink-0 border-t px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-col items-center justify-between gap-3 sm:flex-row">
|
||||
<div className="text-muted-foreground text-xs sm:text-sm">
|
||||
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length}건)
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setStepDataPage((p) => Math.max(1, p - 1))}
|
||||
className={stepDataPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{totalStepDataPages <= 7 ? (
|
||||
Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
onClick={() => setStepDataPage(page)}
|
||||
isActive={stepDataPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{Array.from({ length: totalStepDataPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
return (
|
||||
page === 1 ||
|
||||
page === totalStepDataPages ||
|
||||
(page >= stepDataPage - 2 && page <= stepDataPage + 2)
|
||||
);
|
||||
})
|
||||
.map((page, idx, arr) => (
|
||||
<React.Fragment key={page}>
|
||||
{idx > 0 && arr[idx - 1] !== page - 1 && (
|
||||
<PaginationItem>
|
||||
<span className="text-muted-foreground px-2">...</span>
|
||||
</PaginationItem>
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
onClick={() => setStepDataPage(page)}
|
||||
isActive={stepDataPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setStepDataPage((p) => Math.min(totalStepDataPages, p + 1))}
|
||||
className={
|
||||
stepDataPage === totalStepDataPages ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -204,21 +204,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const fieldName = (component as any).columnName || component.id;
|
||||
const currentValue = formData?.[fieldName] || "";
|
||||
|
||||
console.log("🔍 DynamicComponentRenderer - 새 컴포넌트 시스템:", {
|
||||
componentType,
|
||||
componentId: component.id,
|
||||
columnName: (component as any).columnName,
|
||||
fieldName,
|
||||
currentValue,
|
||||
hasFormData: !!formData,
|
||||
formDataKeys: formData ? Object.keys(formData) : [],
|
||||
autoGeneration: component.autoGeneration,
|
||||
hidden: component.hidden,
|
||||
isInteractive,
|
||||
isPreview, // 반응형 모드 플래그
|
||||
isDesignMode: props.isDesignMode, // 디자인 모드 플래그
|
||||
});
|
||||
|
||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||
const handleChange = (value: any) => {
|
||||
// React 이벤트 객체인 경우 값 추출
|
||||
|
|
@ -226,24 +211,13 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
if (value && typeof value === "object" && value.nativeEvent && value.target) {
|
||||
// SyntheticEvent인 경우 target.value 추출
|
||||
actualValue = value.target.value;
|
||||
console.log("⚠️ DynamicComponentRenderer: 이벤트 객체 감지, value 추출:", actualValue);
|
||||
}
|
||||
|
||||
console.log("🔄 DynamicComponentRenderer handleChange 호출:", {
|
||||
componentType,
|
||||
fieldName,
|
||||
originalValue: value,
|
||||
actualValue,
|
||||
valueType: typeof actualValue,
|
||||
isArray: Array.isArray(actualValue),
|
||||
});
|
||||
|
||||
if (onFormDataChange) {
|
||||
// RepeaterInput 같은 복합 컴포넌트는 전체 데이터를 전달
|
||||
// 단순 input 컴포넌트는 (fieldName, value) 형태로 전달받음
|
||||
if (componentType === "repeater-field-group" || componentType === "repeater") {
|
||||
// fieldName과 함께 전달
|
||||
console.log("💾 RepeaterInput 데이터 저장:", fieldName, actualValue);
|
||||
onFormDataChange(fieldName, actualValue);
|
||||
} else {
|
||||
// 이미 fieldName이 포함된 경우는 그대로 전달
|
||||
|
|
@ -256,18 +230,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리)
|
||||
const { height: _height, ...styleWithoutHeight } = component.style || {};
|
||||
|
||||
// 숨김 값 추출 (디버깅)
|
||||
// 숨김 값 추출
|
||||
const hiddenValue = component.hidden || component.componentConfig?.hidden;
|
||||
if (hiddenValue) {
|
||||
console.log("🔍 DynamicComponentRenderer hidden 체크:", {
|
||||
componentId: component.id,
|
||||
componentType,
|
||||
componentHidden: component.hidden,
|
||||
componentConfigHidden: component.componentConfig?.hidden,
|
||||
finalHiddenValue: hiddenValue,
|
||||
isDesignMode: props.isDesignMode,
|
||||
});
|
||||
}
|
||||
|
||||
const rendererProps = {
|
||||
component,
|
||||
|
|
@ -322,26 +286,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
};
|
||||
|
||||
// 렌더러가 클래스인지 함수인지 확인
|
||||
console.log("🔍🔍 DynamicComponentRenderer - 렌더러 타입 확인:", {
|
||||
componentType,
|
||||
isFunction: typeof NewComponentRenderer === "function",
|
||||
hasPrototype: !!NewComponentRenderer.prototype,
|
||||
hasRenderMethod: !!NewComponentRenderer.prototype?.render,
|
||||
rendererName: NewComponentRenderer.name,
|
||||
});
|
||||
|
||||
if (
|
||||
typeof NewComponentRenderer === "function" &&
|
||||
NewComponentRenderer.prototype &&
|
||||
NewComponentRenderer.prototype.render
|
||||
) {
|
||||
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
|
||||
console.log("✅ 클래스 기반 렌더러로 렌더링:", componentType);
|
||||
const rendererInstance = new NewComponentRenderer(rendererProps);
|
||||
return rendererInstance.render();
|
||||
} else {
|
||||
// 함수형 컴포넌트
|
||||
console.log("✅ 함수형 컴포넌트로 렌더링:", componentType);
|
||||
return <NewComponentRenderer {...rendererProps} />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,19 +73,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
flowSelectedStepId,
|
||||
...props
|
||||
}) => {
|
||||
console.log("🔵 ButtonPrimaryComponent 렌더링, 받은 props:", {
|
||||
componentId: component.id,
|
||||
hasSelectedRowsData: !!selectedRowsData,
|
||||
selectedRowsDataLength: selectedRowsData?.length,
|
||||
selectedRowsData,
|
||||
hasFlowSelectedData: !!flowSelectedData,
|
||||
flowSelectedDataLength: flowSelectedData?.length,
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
tableName,
|
||||
screenId,
|
||||
});
|
||||
|
||||
// 🆕 플로우 단계별 표시 제어
|
||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig;
|
||||
const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId);
|
||||
|
|
@ -101,7 +88,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
if (currentStep === null) {
|
||||
// 🔧 화이트리스트 모드일 때는 단계 미선택 시 숨김
|
||||
if (flowConfig.mode === "whitelist") {
|
||||
console.log("🔍 [ButtonPrimary] 화이트리스트 모드 + 단계 미선택 → 숨김");
|
||||
return false;
|
||||
}
|
||||
// 블랙리스트나 all 모드는 표시
|
||||
|
|
@ -119,18 +105,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
result = true;
|
||||
}
|
||||
|
||||
// 항상 로그 출력
|
||||
console.log("🔍 [ButtonPrimary] 표시 체크:", {
|
||||
buttonId: component.id,
|
||||
buttonLabel: component.label,
|
||||
flowComponentId: flowConfig.targetFlowComponentId,
|
||||
currentStep,
|
||||
mode,
|
||||
visibleSteps,
|
||||
hiddenSteps,
|
||||
result: result ? "표시 ✅" : "숨김 ❌",
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [flowConfig, currentStep, component.id, component.label]);
|
||||
|
||||
|
|
@ -149,7 +123,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentLoadingToastRef.current !== undefined) {
|
||||
console.log("🧹 컴포넌트 언마운트 시 토스트 정리");
|
||||
toast.dismiss(currentLoadingToastRef.current);
|
||||
currentLoadingToastRef.current = undefined;
|
||||
}
|
||||
|
|
@ -240,21 +213,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
};
|
||||
}
|
||||
|
||||
// 디버그 로그 (필요시 주석 해제)
|
||||
// console.log("🔧 버튼 컴포넌트 설정:", {
|
||||
// originalConfig: componentConfig,
|
||||
// processedConfig,
|
||||
// actionConfig: processedConfig.action,
|
||||
// webTypeConfig: component.webTypeConfig,
|
||||
// enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
||||
// dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
||||
// screenId,
|
||||
// tableName,
|
||||
// onRefresh,
|
||||
// onClose,
|
||||
// selectedRows,
|
||||
// selectedRowsData,
|
||||
// });
|
||||
|
||||
// 스타일 계산
|
||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||
|
|
@ -278,12 +236,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 실제 액션 실행 함수
|
||||
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
|
||||
// console.log("🚀 executeAction 시작:", { actionConfig, context });
|
||||
|
||||
try {
|
||||
// 기존 토스트가 있다면 먼저 제거
|
||||
if (currentLoadingToastRef.current !== undefined) {
|
||||
console.log("📱 기존 토스트 제거");
|
||||
toast.dismiss(currentLoadingToastRef.current);
|
||||
currentLoadingToastRef.current = undefined;
|
||||
}
|
||||
|
|
@ -294,7 +249,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// UI 전환 액션(edit, modal, navigate)을 제외하고만 로딩 토스트 표시
|
||||
const silentActions = ["edit", "modal", "navigate"];
|
||||
if (!silentActions.includes(actionConfig.type)) {
|
||||
console.log("📱 로딩 토스트 표시 시작");
|
||||
currentLoadingToastRef.current = toast.loading(
|
||||
actionConfig.type === "save"
|
||||
? "저장 중..."
|
||||
|
|
@ -307,23 +261,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
duration: Infinity, // 명시적으로 무한대로 설정
|
||||
},
|
||||
);
|
||||
console.log("📱 로딩 토스트 ID:", currentLoadingToastRef.current);
|
||||
} else {
|
||||
console.log("🔕 UI 전환 액션은 로딩 토스트 표시 안함:", actionConfig.type);
|
||||
}
|
||||
|
||||
console.log("⚡ ButtonActionExecutor.executeAction 호출 시작");
|
||||
console.log("🔍 actionConfig 확인:", {
|
||||
type: actionConfig.type,
|
||||
successMessage: actionConfig.successMessage,
|
||||
errorMessage: actionConfig.errorMessage,
|
||||
});
|
||||
const success = await ButtonActionExecutor.executeAction(actionConfig, context);
|
||||
console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success);
|
||||
|
||||
// 로딩 토스트 제거 (있는 경우에만)
|
||||
if (currentLoadingToastRef.current !== undefined) {
|
||||
console.log("📱 로딩 토스트 제거 시도, ID:", currentLoadingToastRef.current);
|
||||
toast.dismiss(currentLoadingToastRef.current);
|
||||
currentLoadingToastRef.current = undefined;
|
||||
}
|
||||
|
|
@ -333,11 +276,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// UI 전환 액션(edit, modal, navigate)은 에러도 조용히 처리
|
||||
const silentActions = ["edit", "modal", "navigate"];
|
||||
if (silentActions.includes(actionConfig.type)) {
|
||||
console.log("🔕 UI 전환 액션 실패지만 에러 토스트 표시 안함:", actionConfig.type);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("❌ 액션 실패, 오류 토스트 표시");
|
||||
// 기본 에러 메시지 결정
|
||||
const defaultErrorMessage =
|
||||
actionConfig.type === "save"
|
||||
|
|
@ -357,13 +297,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage;
|
||||
|
||||
console.log("🔍 에러 메시지 결정:", {
|
||||
actionType: actionConfig.type,
|
||||
customMessage: actionConfig.errorMessage,
|
||||
useCustom: useCustomMessage,
|
||||
finalMessage: errorMessage
|
||||
});
|
||||
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
|
@ -390,19 +323,13 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
const successMessage = useCustomMessage ? actionConfig.successMessage : defaultSuccessMessage;
|
||||
|
||||
console.log("🎉 성공 토스트 표시:", successMessage);
|
||||
toast.success(successMessage);
|
||||
} else {
|
||||
console.log("🔕 UI 전환 액션은 조용히 처리 (토스트 없음):", actionConfig.type);
|
||||
}
|
||||
|
||||
console.log("✅ 버튼 액션 실행 성공:", actionConfig.type);
|
||||
|
||||
// 저장/수정 성공 시 자동 처리
|
||||
if (actionConfig.type === "save" || actionConfig.type === "edit") {
|
||||
if (typeof window !== "undefined") {
|
||||
// 1. 테이블 새로고침 이벤트 먼저 발송 (모달이 닫히기 전에)
|
||||
console.log("🔄 저장/수정 후 테이블 새로고침 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
|
||||
// 2. 모달 닫기 (약간의 딜레이)
|
||||
|
|
@ -411,22 +338,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
const isInEditModal = (props as any).isInModal;
|
||||
|
||||
if (isInEditModal) {
|
||||
console.log("🚪 EditModal 닫기 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
}
|
||||
|
||||
// ScreenModal은 항상 닫기
|
||||
console.log("🚪 ScreenModal 닫기 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent("closeSaveModal"));
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("❌ executeAction catch 블록 진입:", error);
|
||||
|
||||
// 로딩 토스트 제거
|
||||
if (currentLoadingToastRef.current !== undefined) {
|
||||
console.log("📱 오류 시 로딩 토스트 제거, ID:", currentLoadingToastRef.current);
|
||||
toast.dismiss(currentLoadingToastRef.current);
|
||||
currentLoadingToastRef.current = undefined;
|
||||
}
|
||||
|
|
@ -441,12 +363,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 이벤트 핸들러
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
console.log("🖱️ 버튼 클릭 이벤트 발생", {
|
||||
isDesignMode,
|
||||
isInteractive,
|
||||
hasAction: !!processedConfig.action,
|
||||
processedConfig,
|
||||
});
|
||||
|
||||
// 디자인 모드에서는 기본 onClick만 실행
|
||||
if (isDesignMode) {
|
||||
|
|
@ -454,29 +370,13 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
console.log("🔍 조건 체크:", {
|
||||
isInteractive,
|
||||
hasProcessedConfig: !!processedConfig,
|
||||
hasAction: !!processedConfig.action,
|
||||
actionType: processedConfig.action?.type,
|
||||
});
|
||||
|
||||
// 인터랙티브 모드에서 액션 실행
|
||||
if (isInteractive && processedConfig.action) {
|
||||
console.log("✅ 액션 실행 조건 통과", {
|
||||
actionType: processedConfig.action.type,
|
||||
requiresConfirmation: confirmationRequiredActions.includes(processedConfig.action.type),
|
||||
});
|
||||
|
||||
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
||||
const hasDataToDelete =
|
||||
(selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
||||
|
||||
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
|
||||
console.log("⚠️ 삭제할 데이터가 선택되지 않았습니다.", {
|
||||
hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0),
|
||||
hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0),
|
||||
});
|
||||
toast.warning("삭제할 항목을 먼저 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -498,22 +398,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
flowSelectedStepId,
|
||||
};
|
||||
|
||||
console.log("🔍 버튼 액션 실행 전 context 확인:", {
|
||||
hasSelectedRowsData: !!selectedRowsData,
|
||||
selectedRowsDataLength: selectedRowsData?.length,
|
||||
selectedRowsData,
|
||||
hasFlowSelectedData: !!flowSelectedData,
|
||||
flowSelectedDataLength: flowSelectedData?.length,
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
tableName,
|
||||
screenId,
|
||||
formData,
|
||||
});
|
||||
|
||||
// 확인이 필요한 액션인지 확인
|
||||
if (confirmationRequiredActions.includes(processedConfig.action.type)) {
|
||||
console.log("📋 확인 다이얼로그 표시 중...");
|
||||
// 확인 다이얼로그 표시
|
||||
setPendingAction({
|
||||
type: processedConfig.action.type,
|
||||
|
|
@ -522,16 +408,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
});
|
||||
setShowConfirmDialog(true);
|
||||
} else {
|
||||
console.log("🚀 액션 바로 실행 중...");
|
||||
// 확인이 필요하지 않은 액션은 바로 실행
|
||||
await executeAction(processedConfig.action, context);
|
||||
}
|
||||
} else {
|
||||
console.log("⚠️ 액션 실행 조건 불만족:", {
|
||||
isInteractive,
|
||||
hasAction: !!processedConfig.action,
|
||||
이유: !isInteractive ? "인터랙티브 모드 아님" : "액션 없음",
|
||||
});
|
||||
// 액션이 설정되지 않은 경우 기본 onClick 실행
|
||||
onClick?.();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -540,13 +540,7 @@ export class OptimizedButtonDataflowService {
|
|||
});
|
||||
|
||||
if (!isValid) {
|
||||
const sourceLabel =
|
||||
context.controlDataSource === "form"
|
||||
? "폼"
|
||||
: context.controlDataSource === "table-selection"
|
||||
? "선택된 항목"
|
||||
: "데이터";
|
||||
|
||||
const sourceLabel = getDataSourceLabel(context.controlDataSource);
|
||||
const actualValueMsg = fieldValue !== undefined ? ` (실제값: ${fieldValue})` : " (값 없음)";
|
||||
|
||||
return {
|
||||
|
|
@ -755,5 +749,29 @@ export class OptimizedButtonDataflowService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스 타입에 따른 한글 레이블 반환
|
||||
*/
|
||||
function getDataSourceLabel(dataSource: string | undefined): string {
|
||||
switch (dataSource) {
|
||||
case "form":
|
||||
return "폼";
|
||||
case "table-selection":
|
||||
return "테이블 선택 항목";
|
||||
case "table-all":
|
||||
return "테이블 전체";
|
||||
case "flow-selection":
|
||||
return "플로우 선택 항목";
|
||||
case "flow-step-all":
|
||||
return "플로우 스텝 전체";
|
||||
case "both":
|
||||
return "폼 + 테이블 선택";
|
||||
case "all-sources":
|
||||
return "모든 소스";
|
||||
default:
|
||||
return "데이터";
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 전역 접근을 위한 싱글톤 서비스
|
||||
export const optimizedButtonDataflowService = OptimizedButtonDataflowService;
|
||||
|
|
|
|||
|
|
@ -778,7 +778,7 @@ export class ButtonActionExecutor {
|
|||
let controlDataSource = config.dataflowConfig.controlDataSource;
|
||||
|
||||
if (!controlDataSource) {
|
||||
// 설정이 없으면 자동 판단
|
||||
// 설정이 없으면 자동 판단 (우선순위 순서대로)
|
||||
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
|
||||
controlDataSource = "flow-selection";
|
||||
console.log("🔄 자동 판단: flow-selection 모드 사용");
|
||||
|
|
@ -794,6 +794,13 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("📊 데이터 소스 모드:", {
|
||||
controlDataSource,
|
||||
hasFormData: !!(context.formData && Object.keys(context.formData).length > 0),
|
||||
hasTableSelection: !!(context.selectedRowsData && context.selectedRowsData.length > 0),
|
||||
hasFlowSelection: !!(context.flowSelectedData && context.flowSelectedData.length > 0),
|
||||
});
|
||||
|
||||
const extendedContext: ExtendedControlContext = {
|
||||
formData: context.formData || {},
|
||||
selectedRows: context.selectedRows || [],
|
||||
|
|
@ -824,31 +831,92 @@ export class ButtonActionExecutor {
|
|||
// 노드 플로우 실행 API 호출 (API 클라이언트 사용)
|
||||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||
|
||||
// 데이터 소스 준비: 플로우 선택, 테이블 선택, 또는 폼 데이터
|
||||
// 데이터 소스 준비: controlDataSource 설정 기반
|
||||
let sourceData: any = null;
|
||||
let dataSourceType: string = "none";
|
||||
let dataSourceType: string = controlDataSource || "none";
|
||||
|
||||
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
|
||||
// 플로우에서 선택된 데이터 사용
|
||||
sourceData = context.flowSelectedData;
|
||||
dataSourceType = "flow-selection";
|
||||
console.log("🌊 플로우 선택 데이터 사용:", {
|
||||
stepId: context.flowSelectedStepId,
|
||||
dataCount: sourceData.length,
|
||||
sourceData,
|
||||
});
|
||||
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
// 테이블에서 선택된 행 데이터 사용
|
||||
sourceData = context.selectedRowsData;
|
||||
dataSourceType = "table-selection";
|
||||
console.log("📊 테이블 선택 데이터 사용:", sourceData);
|
||||
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
// 폼 데이터 사용 (배열로 감싸서 일관성 유지)
|
||||
sourceData = [context.formData];
|
||||
dataSourceType = "form";
|
||||
console.log("📝 폼 데이터 사용:", sourceData);
|
||||
console.log("🔍 데이터 소스 결정:", {
|
||||
controlDataSource,
|
||||
hasFlowSelectedData: !!(context.flowSelectedData && context.flowSelectedData.length > 0),
|
||||
hasSelectedRowsData: !!(context.selectedRowsData && context.selectedRowsData.length > 0),
|
||||
hasFormData: !!(context.formData && Object.keys(context.formData).length > 0),
|
||||
});
|
||||
|
||||
// controlDataSource 설정에 따라 데이터 선택
|
||||
switch (controlDataSource) {
|
||||
case "flow-selection":
|
||||
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
|
||||
sourceData = context.flowSelectedData;
|
||||
console.log("🌊 플로우 선택 데이터 사용:", {
|
||||
stepId: context.flowSelectedStepId,
|
||||
dataCount: sourceData.length,
|
||||
sourceData,
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠️ flow-selection 모드이지만 선택된 플로우 데이터가 없습니다.");
|
||||
}
|
||||
break;
|
||||
|
||||
case "table-selection":
|
||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
sourceData = context.selectedRowsData;
|
||||
console.log("📊 테이블 선택 데이터 사용:", {
|
||||
dataCount: sourceData.length,
|
||||
sourceData,
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠️ table-selection 모드이지만 선택된 행이 없습니다.");
|
||||
}
|
||||
break;
|
||||
|
||||
case "form":
|
||||
if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
sourceData = [context.formData];
|
||||
console.log("📝 폼 데이터 사용:", sourceData);
|
||||
} else {
|
||||
console.warn("⚠️ form 모드이지만 폼 데이터가 없습니다.");
|
||||
}
|
||||
break;
|
||||
|
||||
case "both":
|
||||
// 폼 + 테이블 선택
|
||||
sourceData = [];
|
||||
if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
sourceData.push(context.formData);
|
||||
}
|
||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
sourceData.push(...context.selectedRowsData);
|
||||
}
|
||||
console.log("🔀 폼 + 테이블 선택 데이터 사용:", {
|
||||
dataCount: sourceData.length,
|
||||
sourceData,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// 자동 판단 (설정이 없는 경우)
|
||||
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
|
||||
sourceData = context.flowSelectedData;
|
||||
dataSourceType = "flow-selection";
|
||||
console.log("🌊 [자동] 플로우 선택 데이터 사용");
|
||||
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
sourceData = context.selectedRowsData;
|
||||
dataSourceType = "table-selection";
|
||||
console.log("📊 [자동] 테이블 선택 데이터 사용");
|
||||
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
sourceData = [context.formData];
|
||||
dataSourceType = "form";
|
||||
console.log("📝 [자동] 폼 데이터 사용");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("📦 최종 전달 데이터:", {
|
||||
dataSourceType,
|
||||
sourceDataCount: sourceData ? (Array.isArray(sourceData) ? sourceData.length : 1) : 0,
|
||||
sourceData,
|
||||
});
|
||||
|
||||
const result = await executeNodeFlow(flowId, {
|
||||
dataSourceType,
|
||||
sourceData,
|
||||
|
|
@ -857,10 +925,17 @@ export class ButtonActionExecutor {
|
|||
|
||||
if (result.success) {
|
||||
console.log("✅ 노드 플로우 실행 완료:", result);
|
||||
toast.success(config.successMessage || "플로우 실행이 완료되었습니다.");
|
||||
toast.success("플로우 실행이 완료되었습니다.");
|
||||
|
||||
// 새로고침이 필요한 경우
|
||||
// 플로우 새로고침 (플로우 위젯용)
|
||||
if (context.onFlowRefresh) {
|
||||
console.log("🔄 플로우 새로고침 호출");
|
||||
context.onFlowRefresh();
|
||||
}
|
||||
|
||||
// 테이블 새로고침 (일반 테이블용)
|
||||
if (context.onRefresh) {
|
||||
console.log("🔄 테이블 새로고침 호출");
|
||||
context.onRefresh();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import { ComponentData } from "@/types/screen";
|
||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||
|
||||
/**
|
||||
* 선택된 컴포넌트들이 모두 버튼인지 확인
|
||||
*/
|
||||
export function areAllButtons(components: ComponentData[]): boolean {
|
||||
return components.every((comp) => {
|
||||
return (
|
||||
comp.type === "button" ||
|
||||
(comp.type === "component" && ["button-primary", "button-secondary"].includes((comp as any).componentType))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 버튼들 중 플로우 제어가 활성화된 버튼 찾기
|
||||
*/
|
||||
export function getFlowEnabledButtons(components: ComponentData[]): ComponentData[] {
|
||||
return components.filter((comp) => {
|
||||
const flowConfig = (comp as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
|
||||
return flowConfig?.enabled;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 고유한 그룹 ID 생성
|
||||
*/
|
||||
export function generateGroupId(): string {
|
||||
return `flow-group-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼들을 그룹으로 묶기
|
||||
*/
|
||||
export function groupButtons(
|
||||
buttons: ComponentData[],
|
||||
groupId: string,
|
||||
groupSettings?: {
|
||||
direction?: "horizontal" | "vertical";
|
||||
gap?: number;
|
||||
align?: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
}
|
||||
): ComponentData[] {
|
||||
return buttons.map((button) => {
|
||||
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
|
||||
|
||||
// 플로우 제어가 비활성화되어 있으면 먼저 활성화
|
||||
const updatedConfig: FlowVisibilityConfig = {
|
||||
enabled: currentConfig?.enabled ?? true,
|
||||
targetFlowComponentId: currentConfig?.targetFlowComponentId || "",
|
||||
targetFlowId: currentConfig?.targetFlowId,
|
||||
targetFlowName: currentConfig?.targetFlowName,
|
||||
mode: currentConfig?.mode || "whitelist",
|
||||
visibleSteps: currentConfig?.visibleSteps || [],
|
||||
hiddenSteps: currentConfig?.hiddenSteps || [],
|
||||
layoutBehavior: "auto-compact", // 그룹화 시 자동으로 auto-compact 모드
|
||||
groupId,
|
||||
groupDirection: groupSettings?.direction || currentConfig?.groupDirection || "horizontal",
|
||||
groupGap: groupSettings?.gap ?? currentConfig?.groupGap ?? 8,
|
||||
groupAlign: groupSettings?.align || currentConfig?.groupAlign || "start",
|
||||
};
|
||||
|
||||
return {
|
||||
...button,
|
||||
webTypeConfig: {
|
||||
...(button as any).webTypeConfig,
|
||||
flowVisibilityConfig: updatedConfig,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 그룹 해제
|
||||
*/
|
||||
export function ungroupButtons(buttons: ComponentData[]): ComponentData[] {
|
||||
return buttons.map((button) => {
|
||||
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
|
||||
|
||||
if (!currentConfig) return button;
|
||||
|
||||
const updatedConfig: FlowVisibilityConfig = {
|
||||
...currentConfig,
|
||||
layoutBehavior: "preserve-position", // 그룹 해제 시 preserve-position 모드로
|
||||
groupId: undefined,
|
||||
groupDirection: undefined,
|
||||
groupGap: undefined,
|
||||
groupAlign: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
...button,
|
||||
webTypeConfig: {
|
||||
...(button as any).webTypeConfig,
|
||||
flowVisibilityConfig: updatedConfig,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면의 모든 버튼 그룹 찾기
|
||||
*/
|
||||
export function findAllButtonGroups(components: ComponentData[]): Record<string, ComponentData[]> {
|
||||
const groups: Record<string, ComponentData[]> = {};
|
||||
|
||||
components.forEach((comp) => {
|
||||
const isButton =
|
||||
comp.type === "button" ||
|
||||
(comp.type === "component" && ["button-primary", "button-secondary"].includes((comp as any).componentType));
|
||||
|
||||
if (isButton) {
|
||||
const flowConfig = (comp as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
|
||||
|
||||
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
|
||||
if (!groups[flowConfig.groupId]) {
|
||||
groups[flowConfig.groupId] = [];
|
||||
}
|
||||
groups[flowConfig.groupId].push(comp);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 정보 추출
|
||||
*/
|
||||
export interface ButtonGroupInfo {
|
||||
groupId: string;
|
||||
buttonCount: number;
|
||||
buttons: ComponentData[];
|
||||
direction: "horizontal" | "vertical";
|
||||
gap: number;
|
||||
align: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
targetFlowName?: string;
|
||||
}
|
||||
|
||||
export function getButtonGroupInfo(groupId: string, buttons: ComponentData[]): ButtonGroupInfo | null {
|
||||
if (buttons.length === 0) return null;
|
||||
|
||||
const firstButton = buttons[0];
|
||||
const flowConfig = (firstButton as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig | undefined;
|
||||
|
||||
if (!flowConfig) return null;
|
||||
|
||||
return {
|
||||
groupId,
|
||||
buttonCount: buttons.length,
|
||||
buttons,
|
||||
direction: flowConfig.groupDirection || "horizontal",
|
||||
gap: flowConfig.groupGap ?? 8,
|
||||
align: flowConfig.groupAlign || "start",
|
||||
targetFlowName: flowConfig.targetFlowName,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
/**
|
||||
* 노드 플로우 검증 유틸리티
|
||||
*
|
||||
* 감지 가능한 문제:
|
||||
* 1. 병렬 실행 시 동일 테이블/컬럼 충돌
|
||||
* 2. WHERE 조건 누락 (전체 테이블 삭제/업데이트)
|
||||
* 3. 순환 참조 (무한 루프)
|
||||
* 4. 데이터 소스 타입 불일치
|
||||
*/
|
||||
|
||||
export type ValidationSeverity = "error" | "warning" | "info";
|
||||
|
||||
export interface FlowValidation {
|
||||
nodeId: string;
|
||||
severity: ValidationSeverity;
|
||||
type: string;
|
||||
message: string;
|
||||
affectedNodes?: string[];
|
||||
}
|
||||
|
||||
import type { FlowNode as TypedFlowNode, FlowEdge as TypedFlowEdge } from "@/types/node-editor";
|
||||
|
||||
export type FlowNode = TypedFlowNode;
|
||||
export type FlowEdge = TypedFlowEdge;
|
||||
|
||||
/**
|
||||
* 플로우 전체 검증
|
||||
*/
|
||||
export function validateFlow(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
// 1. 병렬 실행 충돌 검증
|
||||
validations.push(...detectParallelConflicts(nodes, edges));
|
||||
|
||||
// 2. WHERE 조건 누락 검증
|
||||
validations.push(...detectMissingWhereConditions(nodes));
|
||||
|
||||
// 3. 순환 참조 검증
|
||||
validations.push(...detectCircularReferences(nodes, edges));
|
||||
|
||||
// 4. 데이터 소스 타입 불일치 검증
|
||||
validations.push(...detectDataSourceMismatch(nodes, edges));
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 노드에서 도달 가능한 모든 노드 찾기 (DFS)
|
||||
*/
|
||||
function getReachableNodes(
|
||||
startNodeId: string,
|
||||
allNodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowNode[] {
|
||||
const reachable = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
function dfs(nodeId: string) {
|
||||
if (visited.has(nodeId)) return;
|
||||
visited.add(nodeId);
|
||||
reachable.add(nodeId);
|
||||
|
||||
const outgoingEdges = edges.filter((e) => e.source === nodeId);
|
||||
for (const edge of outgoingEdges) {
|
||||
dfs(edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
dfs(startNodeId);
|
||||
|
||||
return allNodes.filter((node) => reachable.has(node.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 병렬 실행 시 동일 테이블/컬럼 충돌 감지
|
||||
*/
|
||||
function detectParallelConflicts(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
// 🆕 연결된 노드만 필터링 (고아 노드 제외)
|
||||
const connectedNodeIds = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
connectedNodeIds.add(edge.source);
|
||||
connectedNodeIds.add(edge.target);
|
||||
}
|
||||
|
||||
// 🆕 소스 노드 찾기
|
||||
const sourceNodes = nodes.filter(
|
||||
(node) =>
|
||||
(node.type === "tableSource" ||
|
||||
node.type === "externalDBSource" ||
|
||||
node.type === "restAPISource") &&
|
||||
connectedNodeIds.has(node.id)
|
||||
);
|
||||
|
||||
// 각 소스 노드에서 시작하는 플로우별로 검증
|
||||
for (const sourceNode of sourceNodes) {
|
||||
// 이 소스에서 도달 가능한 모든 노드 찾기
|
||||
const reachableNodes = getReachableNodes(sourceNode.id, nodes, edges);
|
||||
|
||||
// 레벨별로 그룹화
|
||||
const levels = groupNodesByLevel(
|
||||
reachableNodes,
|
||||
edges.filter(
|
||||
(e) =>
|
||||
reachableNodes.some((n) => n.id === e.source) &&
|
||||
reachableNodes.some((n) => n.id === e.target)
|
||||
)
|
||||
);
|
||||
|
||||
// 각 레벨에서 충돌 검사
|
||||
for (const [levelNum, levelNodes] of levels.entries()) {
|
||||
const updateNodes = levelNodes.filter(
|
||||
(node) => node.type === "updateAction" || node.type === "deleteAction"
|
||||
);
|
||||
|
||||
if (updateNodes.length < 2) continue;
|
||||
|
||||
// 같은 테이블을 수정하는 노드들 찾기
|
||||
const tableMap = new Map<string, FlowNode[]>();
|
||||
|
||||
for (const node of updateNodes) {
|
||||
const tableName =
|
||||
node.data.targetTable || node.data.externalTargetTable;
|
||||
if (tableName) {
|
||||
if (!tableMap.has(tableName)) {
|
||||
tableMap.set(tableName, []);
|
||||
}
|
||||
tableMap.get(tableName)!.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 충돌 검사
|
||||
for (const [tableName, conflictNodes] of tableMap.entries()) {
|
||||
if (conflictNodes.length > 1) {
|
||||
// 같은 컬럼을 수정하는지 확인
|
||||
const fieldMap = new Map<string, FlowNode[]>();
|
||||
|
||||
for (const node of conflictNodes) {
|
||||
const fields = node.data.fieldMappings?.map(
|
||||
(m: any) => m.targetField
|
||||
) || [];
|
||||
for (const field of fields) {
|
||||
if (!fieldMap.has(field)) {
|
||||
fieldMap.set(field, []);
|
||||
}
|
||||
fieldMap.get(field)!.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [field, fieldNodes] of fieldMap.entries()) {
|
||||
if (fieldNodes.length > 1) {
|
||||
validations.push({
|
||||
nodeId: fieldNodes[0].id,
|
||||
severity: "warning",
|
||||
type: "parallel-conflict",
|
||||
message: `병렬 실행 중 '${tableName}.${field}' 컬럼에 대한 충돌 가능성이 있습니다. 실행 순서가 보장되지 않아 예상치 못한 결과가 발생할 수 있습니다.`,
|
||||
affectedNodes: fieldNodes.map((n) => n.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 같은 테이블에 대한 일반 경고
|
||||
if (conflictNodes.length > 1 && fieldMap.size === 0) {
|
||||
validations.push({
|
||||
nodeId: conflictNodes[0].id,
|
||||
severity: "info",
|
||||
type: "parallel-table-access",
|
||||
message: `병렬 실행 중 '${tableName}' 테이블을 동시에 수정합니다.`,
|
||||
affectedNodes: conflictNodes.map((n) => n.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE 조건 누락 감지
|
||||
*/
|
||||
function detectMissingWhereConditions(nodes: FlowNode[]): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === "updateAction" || node.type === "deleteAction") {
|
||||
const whereConditions = node.data.whereConditions;
|
||||
|
||||
if (!whereConditions || whereConditions.length === 0) {
|
||||
validations.push({
|
||||
nodeId: node.id,
|
||||
severity: "error",
|
||||
type: "missing-where",
|
||||
message: `WHERE 조건 없이 전체 테이블을 ${node.type === "deleteAction" ? "삭제" : "수정"}합니다. 매우 위험합니다!`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 순환 참조 감지 (무한 루프)
|
||||
*/
|
||||
function detectCircularReferences(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
// 인접 리스트 생성
|
||||
const adjacencyList = new Map<string, string[]>();
|
||||
for (const node of nodes) {
|
||||
adjacencyList.set(node.id, []);
|
||||
}
|
||||
for (const edge of edges) {
|
||||
adjacencyList.get(edge.source)?.push(edge.target);
|
||||
}
|
||||
|
||||
// DFS로 순환 참조 찾기
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const cycles: string[][] = [];
|
||||
|
||||
function dfs(nodeId: string, path: string[]): void {
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
const neighbors = adjacencyList.get(nodeId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
dfs(neighbor, [...path]);
|
||||
} else if (recursionStack.has(neighbor)) {
|
||||
// 순환 참조 발견
|
||||
const cycleStart = path.indexOf(neighbor);
|
||||
const cycle = path.slice(cycleStart);
|
||||
cycles.push([...cycle, neighbor]);
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!visited.has(node.id)) {
|
||||
dfs(node.id, []);
|
||||
}
|
||||
}
|
||||
|
||||
// 순환 참조 경고 생성
|
||||
for (const cycle of cycles) {
|
||||
const nodeNames = cycle
|
||||
.map((id) => {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
return node?.data.displayName || node?.type || id;
|
||||
})
|
||||
.join(" → ");
|
||||
|
||||
validations.push({
|
||||
nodeId: cycle[0],
|
||||
severity: "error",
|
||||
type: "circular-reference",
|
||||
message: `순환 참조가 감지되었습니다: ${nodeNames}`,
|
||||
affectedNodes: cycle,
|
||||
});
|
||||
}
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스 타입 불일치 감지
|
||||
*/
|
||||
function detectDataSourceMismatch(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
// 각 노드의 데이터 소스 타입 추적
|
||||
const nodeDataSourceTypes = new Map<string, string>();
|
||||
|
||||
// Source 노드들의 타입 수집
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.type === "tableSource" ||
|
||||
node.type === "externalDBSource"
|
||||
) {
|
||||
const dataSourceType = node.data.dataSourceType || "context-data";
|
||||
nodeDataSourceTypes.set(node.id, dataSourceType);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 엣지를 따라 데이터 소스 타입 전파
|
||||
for (const edge of edges) {
|
||||
const sourceType = nodeDataSourceTypes.get(edge.source);
|
||||
if (sourceType) {
|
||||
nodeDataSourceTypes.set(edge.target, sourceType);
|
||||
}
|
||||
}
|
||||
|
||||
// Action 노드들 검사
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.type === "updateAction" ||
|
||||
node.type === "deleteAction" ||
|
||||
node.type === "insertAction"
|
||||
) {
|
||||
const dataSourceType = nodeDataSourceTypes.get(node.id);
|
||||
|
||||
// table-all 모드인데 WHERE에 특정 레코드 조건이 있는 경우
|
||||
if (dataSourceType === "table-all") {
|
||||
const whereConditions = node.data.whereConditions || [];
|
||||
const hasPrimaryKeyCondition = whereConditions.some(
|
||||
(cond: any) => cond.field === "id"
|
||||
);
|
||||
|
||||
if (hasPrimaryKeyCondition) {
|
||||
validations.push({
|
||||
nodeId: node.id,
|
||||
severity: "warning",
|
||||
type: "data-source-mismatch",
|
||||
message: `데이터 소스가 'table-all'이지만 WHERE 조건에 Primary Key가 포함되어 있습니다. 의도한 동작인지 확인하세요.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레벨별로 노드 그룹화 (위상 정렬)
|
||||
*/
|
||||
function groupNodesByLevel(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): Map<number, FlowNode[]> {
|
||||
const levels = new Map<number, FlowNode[]>();
|
||||
const nodeLevel = new Map<string, number>();
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacencyList = new Map<string, string[]>();
|
||||
|
||||
// 초기화
|
||||
for (const node of nodes) {
|
||||
inDegree.set(node.id, 0);
|
||||
adjacencyList.set(node.id, []);
|
||||
}
|
||||
|
||||
// 인접 리스트 및 진입 차수 계산
|
||||
for (const edge of edges) {
|
||||
adjacencyList.get(edge.source)?.push(edge.target);
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
|
||||
}
|
||||
|
||||
// BFS로 레벨 계산
|
||||
const queue: string[] = [];
|
||||
for (const [nodeId, degree] of inDegree.entries()) {
|
||||
if (degree === 0) {
|
||||
queue.push(nodeId);
|
||||
nodeLevel.set(nodeId, 0);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!;
|
||||
const currentLevel = nodeLevel.get(currentId)!;
|
||||
|
||||
const neighbors = adjacencyList.get(currentId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
const newDegree = (inDegree.get(neighbor) || 0) - 1;
|
||||
inDegree.set(neighbor, newDegree);
|
||||
|
||||
if (newDegree === 0) {
|
||||
queue.push(neighbor);
|
||||
nodeLevel.set(neighbor, currentLevel + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 레벨별로 노드 그룹화
|
||||
for (const node of nodes) {
|
||||
const level = nodeLevel.get(node.id) || 0;
|
||||
if (!levels.has(level)) {
|
||||
levels.set(level, []);
|
||||
}
|
||||
levels.get(level)!.push(node);
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 결과 요약
|
||||
*/
|
||||
export function summarizeValidations(validations: FlowValidation[]): {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
infoCount: number;
|
||||
hasBlockingIssues: boolean;
|
||||
} {
|
||||
const errorCount = validations.filter((v) => v.severity === "error").length;
|
||||
const warningCount = validations.filter(
|
||||
(v) => v.severity === "warning"
|
||||
).length;
|
||||
const infoCount = validations.filter((v) => v.severity === "info").length;
|
||||
|
||||
return {
|
||||
errorCount,
|
||||
warningCount,
|
||||
infoCount,
|
||||
hasBlockingIssues: errorCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 노드의 검증 결과 가져오기
|
||||
*/
|
||||
export function getNodeValidations(
|
||||
nodeId: string,
|
||||
validations: FlowValidation[]
|
||||
): FlowValidation[] {
|
||||
return validations.filter(
|
||||
(v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -15,7 +15,18 @@ export interface ButtonExecutionContext {
|
|||
formData: Record<string, any>;
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: Record<string, any>[];
|
||||
controlDataSource?: "form" | "table-selection" | "both";
|
||||
controlDataSource?: "form" | "table-selection" | "table-all" | "flow-selection" | "flow-step-all" | "both" | "all-sources";
|
||||
|
||||
// 🆕 테이블 전체 데이터 (table-all 모드용)
|
||||
tableAllData?: Record<string, any>[];
|
||||
|
||||
// 🆕 플로우 스텝 전체 데이터 (flow-step-all 모드용)
|
||||
flowStepAllData?: Record<string, any>[];
|
||||
flowStepId?: number;
|
||||
|
||||
// 🆕 플로우 선택 데이터 (flow-selection 모드용)
|
||||
flowSelectedData?: Record<string, any>[];
|
||||
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
|
@ -141,15 +152,134 @@ export async function executeButtonWithFlow(
|
|||
* 컨텍스트 데이터 준비
|
||||
*/
|
||||
function prepareContextData(context: ButtonExecutionContext): Record<string, any> {
|
||||
return {
|
||||
// 🔥 controlDataSource 자동 감지 (명시적으로 설정되지 않은 경우)
|
||||
let dataSource = context.controlDataSource;
|
||||
|
||||
if (!dataSource) {
|
||||
// 1. 플로우 선택 데이터가 있으면 flow-selection
|
||||
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
|
||||
dataSource = "flow-selection";
|
||||
logger.info("🔄 자동 판단: flow-selection 모드 사용", {
|
||||
flowSelectedDataLength: context.flowSelectedData.length,
|
||||
});
|
||||
}
|
||||
// 2. 플로우 스텝 전체 데이터가 있으면 flow-step-all
|
||||
else if (context.flowStepAllData && context.flowStepAllData.length > 0) {
|
||||
dataSource = "flow-step-all";
|
||||
logger.info("🔄 자동 판단: flow-step-all 모드 사용", {
|
||||
flowStepAllDataLength: context.flowStepAllData.length,
|
||||
});
|
||||
}
|
||||
// 3. 테이블 선택 데이터가 있으면 table-selection
|
||||
else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
dataSource = "table-selection";
|
||||
logger.info("🔄 자동 판단: table-selection 모드 사용", {
|
||||
selectedRowsDataLength: context.selectedRowsData.length,
|
||||
});
|
||||
}
|
||||
// 4. 테이블 전체 데이터가 있으면 table-all
|
||||
else if (context.tableAllData && context.tableAllData.length > 0) {
|
||||
dataSource = "table-all";
|
||||
logger.info("🔄 자동 판단: table-all 모드 사용", {
|
||||
tableAllDataLength: context.tableAllData.length,
|
||||
});
|
||||
}
|
||||
// 5. 폼 데이터만 있으면 form
|
||||
else {
|
||||
dataSource = "form";
|
||||
logger.info("🔄 자동 판단: form 모드 사용");
|
||||
}
|
||||
}
|
||||
|
||||
const baseContext = {
|
||||
buttonId: context.buttonId,
|
||||
screenId: context.screenId,
|
||||
companyCode: context.companyCode,
|
||||
userId: context.userId,
|
||||
formData: context.formData || {},
|
||||
selectedRowsData: context.selectedRowsData || [],
|
||||
controlDataSource: context.controlDataSource || "form",
|
||||
controlDataSource: dataSource,
|
||||
};
|
||||
|
||||
// 데이터 소스에 따라 데이터 준비
|
||||
|
||||
switch (dataSource) {
|
||||
case "form":
|
||||
return {
|
||||
...baseContext,
|
||||
formData: context.formData || {},
|
||||
sourceData: [context.formData || {}], // 배열로 통일
|
||||
};
|
||||
|
||||
case "table-selection":
|
||||
return {
|
||||
...baseContext,
|
||||
formData: context.formData || {},
|
||||
selectedRowsData: context.selectedRowsData || [],
|
||||
sourceData: context.selectedRowsData || [],
|
||||
};
|
||||
|
||||
case "table-all":
|
||||
return {
|
||||
...baseContext,
|
||||
formData: context.formData || {},
|
||||
tableAllData: context.tableAllData || [],
|
||||
sourceData: context.tableAllData || [],
|
||||
};
|
||||
|
||||
case "flow-selection":
|
||||
return {
|
||||
...baseContext,
|
||||
formData: context.formData || {},
|
||||
flowSelectedData: context.flowSelectedData || [],
|
||||
sourceData: context.flowSelectedData || [],
|
||||
};
|
||||
|
||||
case "flow-step-all":
|
||||
return {
|
||||
...baseContext,
|
||||
formData: context.formData || {},
|
||||
flowStepAllData: context.flowStepAllData || [],
|
||||
flowStepId: context.flowStepId,
|
||||
sourceData: context.flowStepAllData || [],
|
||||
};
|
||||
|
||||
case "both":
|
||||
// 폼 + 테이블 선택
|
||||
return {
|
||||
...baseContext,
|
||||
formData: context.formData || {},
|
||||
selectedRowsData: context.selectedRowsData || [],
|
||||
sourceData: [
|
||||
context.formData || {},
|
||||
...(context.selectedRowsData || []),
|
||||
],
|
||||
};
|
||||
|
||||
case "all-sources":
|
||||
// 모든 소스 결합
|
||||
return {
|
||||
...baseContext,
|
||||
formData: context.formData || {},
|
||||
selectedRowsData: context.selectedRowsData || [],
|
||||
tableAllData: context.tableAllData || [],
|
||||
flowSelectedData: context.flowSelectedData || [],
|
||||
flowStepAllData: context.flowStepAllData || [],
|
||||
sourceData: [
|
||||
context.formData || {},
|
||||
...(context.selectedRowsData || []),
|
||||
...(context.tableAllData || []),
|
||||
...(context.flowSelectedData || []),
|
||||
...(context.flowStepAllData || []),
|
||||
].filter(item => Object.keys(item).length > 0), // 빈 객체 제거
|
||||
};
|
||||
|
||||
default:
|
||||
logger.warn(`알 수 없는 데이터 소스: ${dataSource}, 기본값(form) 사용`);
|
||||
return {
|
||||
...baseContext,
|
||||
formData: context.formData || {},
|
||||
sourceData: [context.formData || {}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -49,47 +49,24 @@ export const useFlowStepStore = create<FlowStepState>()(
|
|||
selectedSteps: {},
|
||||
|
||||
setSelectedStep: (flowComponentId, stepId) => {
|
||||
console.log("🔄 [FlowStepStore] 플로우 단계 변경:", {
|
||||
flowComponentId,
|
||||
stepId,
|
||||
stepName: stepId ? `Step ${stepId}` : "선택 해제",
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
selectedSteps: {
|
||||
...state.selectedSteps,
|
||||
[flowComponentId]: stepId,
|
||||
},
|
||||
}));
|
||||
|
||||
// 개발 모드에서 현재 상태 출력
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const currentState = get().selectedSteps;
|
||||
console.log("📊 [FlowStepStore] 현재 상태:", currentState);
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentStep: (flowComponentId) => {
|
||||
const stepId = get().selectedSteps[flowComponentId] || null;
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("🔍 [FlowStepStore] 현재 단계 조회:", {
|
||||
flowComponentId,
|
||||
stepId,
|
||||
});
|
||||
}
|
||||
|
||||
return stepId;
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
console.log("🔄 [FlowStepStore] 모든 플로우 단계 초기화");
|
||||
set({ selectedSteps: {} });
|
||||
},
|
||||
|
||||
resetFlow: (flowComponentId) => {
|
||||
console.log("🔄 [FlowStepStore] 플로우 단계 초기화:", flowComponentId);
|
||||
|
||||
set((state) => {
|
||||
const { [flowComponentId]: _, ...rest } = state.selectedSteps;
|
||||
return { selectedSteps: rest };
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export interface FlowVisibilityConfig {
|
|||
visibleSteps?: number[];
|
||||
hiddenSteps?: number[];
|
||||
layoutBehavior: "preserve-position" | "auto-compact";
|
||||
groupId?: string;
|
||||
groupDirection?: "horizontal" | "vertical";
|
||||
groupGap?: number;
|
||||
groupAlign?: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -113,8 +117,23 @@ export interface ButtonDataflowConfig {
|
|||
|
||||
/**
|
||||
* 제어 데이터 소스 타입
|
||||
*
|
||||
* - form: 폼 데이터만 사용
|
||||
* - table-selection: 테이블에서 선택된 행 데이터만 사용
|
||||
* - table-all: 테이블의 전체 데이터 사용 (페이징 무관, 모든 데이터)
|
||||
* - flow-selection: 플로우에서 선택된 데이터만 사용
|
||||
* - flow-step-all: 특정 플로우 스텝의 모든 데이터 사용
|
||||
* - both: 폼 + 테이블 선택 데이터 결합
|
||||
* - all-sources: 모든 소스 데이터 결합 (폼 + 테이블 전체 + 플로우)
|
||||
*/
|
||||
export type ControlDataSource = "form" | "table-selection" | "flow-selection" | "both";
|
||||
export type ControlDataSource =
|
||||
| "form"
|
||||
| "table-selection"
|
||||
| "table-all"
|
||||
| "flow-selection"
|
||||
| "flow-step-all"
|
||||
| "both"
|
||||
| "all-sources";
|
||||
|
||||
/**
|
||||
* 직접 제어 설정
|
||||
|
|
|
|||
|
|
@ -337,9 +337,30 @@ export interface FlowVisibilityConfig {
|
|||
/**
|
||||
* 레이아웃 동작 방식
|
||||
* - preserve-position: 원래 위치 유지 (display: none, 빈 공간 유지)
|
||||
* - auto-compact: 빈 공간 자동 제거 (Flexbox, 렌더링하지 않음)
|
||||
* - auto-compact: 빈 공간 자동 제거 (Flexbox 그룹으로 자동 정렬)
|
||||
*/
|
||||
layoutBehavior: "preserve-position" | "auto-compact";
|
||||
|
||||
/**
|
||||
* 그룹 ID (auto-compact 모드일 때 사용)
|
||||
* 같은 그룹 ID를 가진 버튼들이 하나의 FlowButtonGroup으로 묶임
|
||||
*/
|
||||
groupId?: string;
|
||||
|
||||
/**
|
||||
* 그룹 정렬 방향 (auto-compact 모드일 때 사용)
|
||||
*/
|
||||
groupDirection?: "horizontal" | "vertical";
|
||||
|
||||
/**
|
||||
* 그룹 내 버튼 간격 (px, auto-compact 모드일 때 사용)
|
||||
*/
|
||||
groupGap?: number;
|
||||
|
||||
/**
|
||||
* 그룹 정렬 방식 (auto-compact 모드일 때 사용)
|
||||
*/
|
||||
groupAlign?: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
}
|
||||
|
||||
// ===== 데이터 테이블 관련 =====
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
# 노드 플로우 데이터 소스 설정 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
노드 플로우 편집기에서 **테이블 소스 노드**와 **외부 DB 소스 노드**에 데이터 소스 타입을 설정할 수 있습니다. 이제 버튼에서 전달된 데이터를 사용할지, 아니면 테이블의 전체 데이터를 직접 조회할지 선택할 수 있습니다.
|
||||
|
||||
## 지원 노드
|
||||
|
||||
### 1. 테이블 소스 노드 (내부 DB)
|
||||
- **위치**: 노드 팔레트 > 데이터 소스 > 테이블 소스
|
||||
- **용도**: 내부 데이터베이스의 테이블 데이터 조회
|
||||
|
||||
### 2. 외부 DB 소스 노드
|
||||
- **위치**: 노드 팔레트 > 데이터 소스 > 외부 DB 소스
|
||||
- **용도**: 외부 데이터베이스의 테이블 데이터 조회
|
||||
|
||||
## 데이터 소스 타입
|
||||
|
||||
### 1. 컨텍스트 데이터 (기본값)
|
||||
```
|
||||
💡 컨텍스트 데이터 모드
|
||||
버튼 실행 시 전달된 데이터(폼 데이터, 테이블 선택 항목 등)를 사용합니다.
|
||||
|
||||
사용 예시:
|
||||
• 폼 데이터: 1개 레코드
|
||||
• 테이블 선택: N개 레코드
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- ✅ 버튼에서 제어한 데이터만 처리
|
||||
- ✅ 성능 우수 (필요한 데이터만 사용)
|
||||
- ✅ 사용자가 선택한 데이터만 처리
|
||||
- ⚠️ 버튼 설정에서 데이터 소스를 올바르게 설정해야 함
|
||||
|
||||
**사용 시나리오:**
|
||||
- 폼 데이터로 새 레코드 생성
|
||||
- 테이블에서 선택한 항목 일괄 업데이트
|
||||
- 사용자가 선택한 데이터만 처리
|
||||
|
||||
### 2. 테이블 전체 데이터
|
||||
```
|
||||
📊 테이블 전체 데이터 모드
|
||||
선택한 테이블의 **모든 행**을 직접 조회합니다.
|
||||
|
||||
⚠️ 대량 데이터 시 성능 주의
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- ✅ 테이블의 모든 데이터 처리
|
||||
- ✅ 버튼 설정과 무관하게 동작
|
||||
- ✅ 자동으로 전체 데이터 조회
|
||||
- ⚠️ 대량 데이터 시 메모리 및 성능 이슈 가능
|
||||
- ⚠️ 네트워크 부하 증가
|
||||
|
||||
**사용 시나리오:**
|
||||
- 전체 데이터 통계/집계
|
||||
- 일괄 데이터 마이그레이션
|
||||
- 전체 데이터 검증
|
||||
- 백업/복원 작업
|
||||
|
||||
## 설정 방법
|
||||
|
||||
### 1단계: 노드 추가
|
||||
1. 노드 플로우 편집기 열기
|
||||
2. 좌측 팔레트에서 **테이블 소스** 또는 **외부 DB 소스** 드래그
|
||||
3. 캔버스에 노드 배치
|
||||
|
||||
### 2단계: 테이블 선택
|
||||
1. 노드 클릭하여 선택
|
||||
2. 우측 **속성 패널** 열림
|
||||
3. **테이블 선택** 드롭다운에서 테이블 선택
|
||||
|
||||
### 3단계: 데이터 소스 설정
|
||||
1. **데이터 소스 설정** 섹션으로 스크롤
|
||||
2. **데이터 소스 타입** 드롭다운 클릭
|
||||
3. 원하는 모드 선택:
|
||||
- **컨텍스트 데이터**: 버튼에서 전달된 데이터 사용
|
||||
- **테이블 전체 데이터**: 테이블의 모든 행 조회
|
||||
|
||||
### 4단계: 저장
|
||||
- 변경 사항은 **즉시 노드에 반영**됩니다.
|
||||
- 별도 저장 버튼 불필요 (자동 저장)
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 예시 1: 선택된 항목만 처리 (컨텍스트 데이터)
|
||||
|
||||
**시나리오**: 사용자가 테이블에서 선택한 주문만 승인 처리
|
||||
|
||||
**플로우 구성:**
|
||||
```
|
||||
[테이블 소스: orders]
|
||||
└─ 데이터 소스: 컨텍스트 데이터
|
||||
└─ [조건: status = 'pending']
|
||||
└─ [업데이트: status = 'approved']
|
||||
```
|
||||
|
||||
**버튼 설정:**
|
||||
- 제어 데이터 소스: `table-selection` (테이블 선택 항목)
|
||||
|
||||
**실행 결과:**
|
||||
- 사용자가 선택한 3개 주문만 승인 처리
|
||||
- 나머지 주문은 변경되지 않음
|
||||
|
||||
### 예시 2: 전체 데이터 일괄 처리 (테이블 전체 데이터)
|
||||
|
||||
**시나리오**: 모든 고객의 등급을 재계산
|
||||
|
||||
**플로우 구성:**
|
||||
```
|
||||
[테이블 소스: customers]
|
||||
└─ 데이터 소스: 테이블 전체 데이터
|
||||
└─ [데이터 변환: 등급 계산]
|
||||
└─ [업데이트: grade = 계산된 등급]
|
||||
```
|
||||
|
||||
**버튼 설정:**
|
||||
- 제어 데이터 소스: 무관 (테이블 전체를 자동 조회)
|
||||
|
||||
**실행 결과:**
|
||||
- 모든 고객 레코드의 등급 재계산
|
||||
- 1,000개 고객 → 1,000개 모두 업데이트
|
||||
|
||||
### 예시 3: 외부 DB 전체 데이터 동기화
|
||||
|
||||
**시나리오**: 외부 ERP의 모든 제품 정보를 내부 DB로 동기화
|
||||
|
||||
**플로우 구성:**
|
||||
```
|
||||
[외부 DB 소스: products]
|
||||
└─ 데이터 소스: 테이블 전체 데이터
|
||||
└─ [Upsert: 내부 DB products 테이블]
|
||||
```
|
||||
|
||||
**실행 결과:**
|
||||
- 외부 DB의 모든 제품 데이터 조회
|
||||
- 내부 DB에 동기화 (있으면 업데이트, 없으면 삽입)
|
||||
|
||||
## 노드 실행 로직
|
||||
|
||||
### 컨텍스트 데이터 모드 실행 흐름
|
||||
|
||||
```typescript
|
||||
// 1. 버튼 클릭
|
||||
// 2. 버튼에서 데이터 전달 (폼, 테이블 선택 등)
|
||||
// 3. 노드 플로우 실행
|
||||
// 4. 테이블 소스 노드가 전달받은 데이터 사용
|
||||
|
||||
{
|
||||
nodeType: "tableSource",
|
||||
config: {
|
||||
tableName: "orders",
|
||||
dataSourceType: "context-data"
|
||||
},
|
||||
// 실행 시 버튼에서 전달된 데이터 사용
|
||||
input: [
|
||||
{ id: 1, status: "pending" },
|
||||
{ id: 2, status: "pending" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 테이블 전체 데이터 모드 실행 흐름
|
||||
|
||||
```typescript
|
||||
// 1. 버튼 클릭
|
||||
// 2. 노드 플로우 실행
|
||||
// 3. 테이블 소스 노드가 직접 DB 조회
|
||||
// 4. 모든 행을 반환
|
||||
|
||||
{
|
||||
nodeType: "tableSource",
|
||||
config: {
|
||||
tableName: "orders",
|
||||
dataSourceType: "table-all"
|
||||
},
|
||||
// 실행 시 DB에서 전체 데이터 조회
|
||||
query: "SELECT * FROM orders",
|
||||
output: [
|
||||
{ id: 1, status: "pending" },
|
||||
{ id: 2, status: "approved" },
|
||||
{ id: 3, status: "completed" },
|
||||
// ... 수천 개의 행
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 성능 고려사항
|
||||
|
||||
### 컨텍스트 데이터 모드
|
||||
- ✅ **성능 우수**: 필요한 데이터만 처리
|
||||
- ✅ **메모리 효율**: 선택된 데이터만 메모리에 로드
|
||||
- ✅ **네트워크 효율**: 최소한의 데이터 전송
|
||||
|
||||
### 테이블 전체 데이터 모드
|
||||
- ⚠️ **대량 데이터 주의**: 수천~수만 개 행 처리 시 느려질 수 있음
|
||||
- ⚠️ **메모리 사용**: 모든 데이터를 메모리에 로드
|
||||
- ⚠️ **네트워크 부하**: 전체 데이터 전송
|
||||
|
||||
**권장 사항:**
|
||||
```
|
||||
• 데이터가 1,000개 이하: 테이블 전체 데이터 사용 가능
|
||||
• 데이터가 10,000개 이상: 컨텍스트 데이터 + 필터링 권장
|
||||
• 데이터가 100,000개 이상: 배치 처리 또는 서버 사이드 처리 필요
|
||||
```
|
||||
|
||||
## 디버깅
|
||||
|
||||
### 콘솔 로그 확인
|
||||
|
||||
**데이터 소스 타입 변경 시:**
|
||||
```
|
||||
✅ 데이터 소스 타입 변경: table-all
|
||||
```
|
||||
|
||||
**노드 실행 시:**
|
||||
```typescript
|
||||
// 컨텍스트 데이터 모드
|
||||
🔍 테이블 소스 노드 실행: orders
|
||||
📊 입력 데이터: 3건 (컨텍스트에서 전달됨)
|
||||
|
||||
// 테이블 전체 데이터 모드
|
||||
🔍 테이블 소스 노드 실행: orders
|
||||
📊 테이블 전체 데이터 조회: 1,234건
|
||||
```
|
||||
|
||||
### 일반적인 문제
|
||||
|
||||
#### Q1: 컨텍스트 데이터 모드인데 데이터가 없습니다
|
||||
**A**: 버튼 설정을 확인하세요.
|
||||
- 버튼 설정 > 제어 데이터 소스가 올바르게 설정되어 있는지 확인
|
||||
- 폼 데이터: `form`
|
||||
- 테이블 선택: `table-selection`
|
||||
- 테이블 전체: `table-all`
|
||||
|
||||
#### Q2: 테이블 전체 데이터 모드가 느립니다
|
||||
**A**:
|
||||
1. 데이터 양 확인 (몇 개 행인지?)
|
||||
2. 필요하면 컨텍스트 데이터 + 필터링으로 변경
|
||||
3. WHERE 조건으로 범위 제한
|
||||
|
||||
#### Q3: 외부 DB 소스가 오래 걸립니다
|
||||
**A**:
|
||||
1. 외부 DB 연결 상태 확인
|
||||
2. 네트워크 지연 확인
|
||||
3. 외부 DB의 인덱스 확인
|
||||
|
||||
## 버튼 설정과의 관계
|
||||
|
||||
### 버튼 데이터 소스 vs 노드 데이터 소스
|
||||
|
||||
| 버튼 설정 | 노드 설정 | 결과 |
|
||||
|---------|---------|-----|
|
||||
| `table-selection` | `context-data` | 선택된 항목만 처리 ✅ |
|
||||
| `table-all` | `context-data` | 전체 데이터 전달됨 ⚠️ |
|
||||
| 무관 | `table-all` | 노드가 직접 전체 조회 ✅ |
|
||||
| `form` | `context-data` | 폼 데이터만 처리 ✅ |
|
||||
|
||||
**권장 조합:**
|
||||
```
|
||||
1. 선택된 항목 처리:
|
||||
버튼: table-selection → 노드: context-data
|
||||
|
||||
2. 테이블 전체 처리:
|
||||
버튼: 무관 → 노드: table-all
|
||||
|
||||
3. 폼 데이터 처리:
|
||||
버튼: form → 노드: context-data
|
||||
```
|
||||
|
||||
## 마이그레이션 가이드
|
||||
|
||||
### 기존 노드 업데이트
|
||||
|
||||
기존에 생성된 노드는 **자동으로 `context-data` 모드**로 설정됩니다.
|
||||
|
||||
**업데이트 방법:**
|
||||
1. 노드 선택
|
||||
2. 속성 패널 열기
|
||||
3. 데이터 소스 설정 섹션에서 `table-all`로 변경
|
||||
|
||||
## 베스트 프랙티스
|
||||
|
||||
### ✅ 좋은 예
|
||||
|
||||
```typescript
|
||||
// 시나리오: 사용자가 선택한 주문 취소
|
||||
[테이블 소스: orders]
|
||||
dataSourceType: "context-data" // ✅ 선택된 주문만 처리
|
||||
↓
|
||||
[업데이트: status = 'cancelled']
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 시나리오: 모든 만료된 쿠폰 삭제
|
||||
[테이블 소스: coupons]
|
||||
dataSourceType: "table-all" // ✅ 전체 조회 후 필터링
|
||||
↓
|
||||
[조건: expiry_date < today]
|
||||
↓
|
||||
[삭제]
|
||||
```
|
||||
|
||||
### ❌ 나쁜 예
|
||||
|
||||
```typescript
|
||||
// 시나리오: 단일 주문 업데이트인데 전체 조회
|
||||
[테이블 소스: orders]
|
||||
dataSourceType: "table-all" // ❌ 불필요한 전체 조회
|
||||
↓
|
||||
[조건: id = 123] // 한 개만 필요한데 전체를 조회함
|
||||
↓
|
||||
[업데이트]
|
||||
```
|
||||
|
||||
## 요약
|
||||
|
||||
### 언제 어떤 모드를 사용해야 하나요?
|
||||
|
||||
| 상황 | 권장 모드 |
|
||||
|------|----------|
|
||||
| 폼 데이터로 새 레코드 생성 | 컨텍스트 데이터 |
|
||||
| 테이블에서 선택한 항목 수정 | 컨텍스트 데이터 |
|
||||
| 전체 데이터 통계/집계 | 테이블 전체 데이터 |
|
||||
| 일괄 데이터 마이그레이션 | 테이블 전체 데이터 |
|
||||
| 특정 조건의 데이터 처리 | 테이블 전체 데이터 + 조건 |
|
||||
| 외부 DB 동기화 | 테이블 전체 데이터 |
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **기본은 컨텍스트 데이터**: 대부분의 경우 이것으로 충분합니다.
|
||||
2. **전체 데이터는 신중히**: 성능 영향을 고려하세요.
|
||||
3. **버튼과 노드를 함께 설계**: 데이터 흐름을 명확히 이해하세요.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [제어관리_데이터소스_확장_가이드.md](./제어관리_데이터소스_확장_가이드.md) - 버튼 데이터 소스 설정
|
||||
- 노드 플로우 기본 가이드 (준비 중)
|
||||
|
||||
## 업데이트 이력
|
||||
|
||||
- **2025-01-24**: 초기 문서 작성
|
||||
- 테이블 소스 노드에 데이터 소스 타입 추가
|
||||
- 외부 DB 소스 노드에 데이터 소스 타입 추가
|
||||
- `context-data`, `table-all` 모드 지원
|
||||
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
# 데이터 소스 일관성 개선 완료
|
||||
|
||||
## 문제점
|
||||
|
||||
기존에는 데이터 소스 설정이 일관성 없이 동작했습니다:
|
||||
|
||||
- ❌ 테이블 위젯에서 선택한 행 → 노드는 선택된 행만 처리
|
||||
- ❌ 플로우 위젯에서 선택한 데이터 → 노드는 **전체 테이블** 조회 (예상과 다름)
|
||||
- ❌ 노드에 `dataSourceType` 설정이 있어도 백엔드가 무시
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 1. 백엔드 로직 개선
|
||||
|
||||
#### 테이블 소스 노드 (내부 DB)
|
||||
|
||||
```typescript
|
||||
// nodeFlowExecutionService.ts - executeTableSource()
|
||||
|
||||
const nodeDataSourceType = dataSourceType || "context-data";
|
||||
|
||||
if (nodeDataSourceType === "context-data") {
|
||||
// 버튼에서 전달된 데이터 사용 (폼, 선택 항목 등)
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
if (nodeDataSourceType === "table-all") {
|
||||
// 테이블 전체 데이터를 직접 조회
|
||||
const sql = `SELECT * FROM ${tableName}`;
|
||||
return await query(sql);
|
||||
}
|
||||
```
|
||||
|
||||
#### 외부 DB 소스 노드
|
||||
|
||||
```typescript
|
||||
// nodeFlowExecutionService.ts - executeExternalDBSource()
|
||||
|
||||
const nodeDataSourceType = dataSourceType || "context-data";
|
||||
|
||||
if (nodeDataSourceType === "context-data") {
|
||||
// 버튼에서 전달된 데이터 사용
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
if (nodeDataSourceType === "table-all") {
|
||||
// 외부 DB 테이블 전체 데이터를 직접 조회
|
||||
const result = await poolService.executeQuery(connectionId, sql);
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 데이터 흐름 정리
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 버튼 클릭 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ 버튼 데이터 소스 설정: │
|
||||
│ - form │
|
||||
│ - table-selection │
|
||||
│ - table-all │
|
||||
│ - flow-selection │
|
||||
│ - flow-step-all │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
prepareContextData()
|
||||
(버튼에서 설정한 데이터 준비)
|
||||
↓
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ contextData = { │
|
||||
│ sourceData: [...] // 버튼에서 전달된 데이터 │
|
||||
│ formData: {...} │
|
||||
│ selectedRowsData: [...] │
|
||||
│ tableAllData: [...] │
|
||||
│ } │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
노드 플로우 실행
|
||||
↓
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 테이블 소스 노드 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ 노드 데이터 소스 설정: │
|
||||
│ │
|
||||
│ context-data 모드: │
|
||||
│ → contextData.sourceData 사용 │
|
||||
│ → 버튼에서 전달된 데이터 그대로 사용 │
|
||||
│ │
|
||||
│ table-all 모드: │
|
||||
│ → contextData 무시 │
|
||||
│ → DB에서 테이블 전체 데이터 직접 조회 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 사용 시나리오
|
||||
|
||||
### 시나리오 1: 선택된 항목만 처리
|
||||
|
||||
```
|
||||
[버튼 설정]
|
||||
- 데이터 소스: table-selection
|
||||
|
||||
[노드 설정]
|
||||
- 테이블 소스 노드: context-data
|
||||
|
||||
[결과]
|
||||
✅ 사용자가 선택한 행만 제어 실행
|
||||
```
|
||||
|
||||
### 시나리오 2: 테이블 전체 처리 (버튼 방식)
|
||||
|
||||
```
|
||||
[버튼 설정]
|
||||
- 데이터 소스: table-all
|
||||
|
||||
[노드 설정]
|
||||
- 테이블 소스 노드: context-data
|
||||
|
||||
[결과]
|
||||
✅ 버튼이 테이블 전체 데이터를 로드하여 전달
|
||||
✅ 노드는 전달받은 전체 데이터 처리
|
||||
```
|
||||
|
||||
### 시나리오 3: 테이블 전체 처리 (노드 방식)
|
||||
|
||||
```
|
||||
[버튼 설정]
|
||||
- 데이터 소스: 무관 (또는 form)
|
||||
|
||||
[노드 설정]
|
||||
- 테이블 소스 노드: table-all
|
||||
|
||||
[결과]
|
||||
✅ 버튼 데이터 무시
|
||||
✅ 노드가 직접 테이블 전체 데이터 조회
|
||||
```
|
||||
|
||||
### 시나리오 4: 폼 데이터로 처리
|
||||
|
||||
```
|
||||
[버튼 설정]
|
||||
- 데이터 소스: form
|
||||
|
||||
[노드 설정]
|
||||
- 테이블 소스 노드: context-data
|
||||
|
||||
[결과]
|
||||
✅ 폼 입력값만 제어 실행
|
||||
```
|
||||
|
||||
## 일관성 규칙
|
||||
|
||||
### 규칙 1: 노드가 context-data 모드일 때
|
||||
- **버튼에서 전달된 데이터를 그대로 사용**
|
||||
- 버튼의 `controlDataSource` 설정이 중요
|
||||
- `form` → 폼 데이터 사용
|
||||
- `table-selection` → 선택된 행 사용
|
||||
- `table-all` → 테이블 전체 사용 (버튼이 로드)
|
||||
- `flow-selection` → 플로우 선택 항목 사용
|
||||
|
||||
### 규칙 2: 노드가 table-all 모드일 때
|
||||
- **버튼 설정 무시**
|
||||
- 노드가 직접 DB에서 전체 데이터 조회
|
||||
- 대량 데이터 시 성능 주의
|
||||
|
||||
### 규칙 3: 기본 동작
|
||||
- 노드의 `dataSourceType`이 없으면 `context-data` 기본값
|
||||
- 버튼의 `controlDataSource`가 없으면 자동 판단
|
||||
|
||||
## 권장 사항
|
||||
|
||||
### 일반적인 사용 패턴
|
||||
|
||||
| 상황 | 버튼 설정 | 노드 설정 |
|
||||
|------|----------|----------|
|
||||
| 선택 항목 처리 | `table-selection` | `context-data` |
|
||||
| 폼 데이터 처리 | `form` | `context-data` |
|
||||
| 전체 데이터 처리 (소량) | `table-all` | `context-data` |
|
||||
| 전체 데이터 처리 (대량) | `form` 또는 무관 | `table-all` |
|
||||
| 플로우 선택 처리 | `flow-selection` | `context-data` |
|
||||
|
||||
### 성능 고려사항
|
||||
|
||||
**버튼에서 전체 로드 vs 노드에서 전체 조회:**
|
||||
|
||||
```
|
||||
버튼 방식 (table-all):
|
||||
장점: 한 번만 조회하여 여러 노드에서 재사용 가능
|
||||
단점: 플로우 실행 전에 전체 데이터 로드 (시작 지연)
|
||||
|
||||
노드 방식 (table-all):
|
||||
장점: 필요한 노드만 조회 (선택적 로드)
|
||||
단점: 여러 노드에서 사용 시 중복 조회
|
||||
|
||||
권장: 데이터가 많으면 노드 방식, 재사용이 많으면 버튼 방식
|
||||
```
|
||||
|
||||
## 로그 확인
|
||||
|
||||
### 성공적인 실행 예시
|
||||
|
||||
```
|
||||
📊 테이블 소스 노드 실행: orders, dataSourceType=context-data
|
||||
📊 컨텍스트 데이터 사용: table-selection, 3건
|
||||
✅ 노드 실행 완료: 3건 처리
|
||||
|
||||
또는
|
||||
|
||||
📊 테이블 소스 노드 실행: customers, dataSourceType=table-all
|
||||
📊 테이블 전체 데이터 조회: customers, 1,234건
|
||||
✅ 노드 실행 완료: 1,234건 처리
|
||||
```
|
||||
|
||||
### 문제가 있는 경우
|
||||
|
||||
```
|
||||
⚠️ context-data 모드이지만 전달된 데이터가 없습니다. 빈 배열 반환.
|
||||
|
||||
해결: 버튼의 controlDataSource 설정 확인
|
||||
```
|
||||
|
||||
## 업데이트 내역
|
||||
|
||||
- **2025-01-24**: 백엔드 로직 개선 완료
|
||||
- `executeTableSource()` 함수에 `dataSourceType` 처리 추가
|
||||
- `executeExternalDBSource()` 함수에 `dataSourceType` 처리 추가
|
||||
- 노드 설정이 올바르게 반영되도록 수정
|
||||
- 일관성 있는 데이터 흐름 확립
|
||||
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
# 속성 패널 스크롤 문제 해결 가이드
|
||||
|
||||
## 적용된 수정사항
|
||||
|
||||
### 1. PropertiesPanel.tsx
|
||||
```tsx
|
||||
// 최상위 컨테이너
|
||||
<div className="flex h-full w-full flex-col">
|
||||
|
||||
// 헤더 (고정 높이)
|
||||
<div className="flex h-16 shrink-0 items-center justify-between border-b bg-white p-4">
|
||||
|
||||
// 스크롤 영역 (중요!)
|
||||
<div
|
||||
className="flex-1 overflow-y-scroll bg-gray-50"
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 64px)',
|
||||
overflowY: 'scroll',
|
||||
WebkitOverflowScrolling: 'touch'
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
### 2. FlowEditor.tsx
|
||||
```tsx
|
||||
// 속성 패널 컨테이너 단순화
|
||||
<div className="h-full w-[350px] border-l bg-white">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. TableSourceProperties.tsx / ExternalDBSourceProperties.tsx
|
||||
```tsx
|
||||
// ScrollArea 제거, 일반 div 사용
|
||||
<div className="min-h-full space-y-4 p-4">
|
||||
{/* 컨텐츠 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## 테스트 방법
|
||||
|
||||
1. **브라우저 강제 새로고침**
|
||||
- Windows: `Ctrl + Shift + R` 또는 `Ctrl + F5`
|
||||
- Mac: `Cmd + Shift + R`
|
||||
|
||||
2. **노드 플로우 편집기 열기**
|
||||
- 관리자 메뉴 > 플로우 관리
|
||||
|
||||
3. **테스트 노드 추가**
|
||||
- 테이블 소스 노드를 캔버스에 드래그
|
||||
|
||||
4. **속성 패널 확인**
|
||||
- 노드 클릭
|
||||
- 우측에 속성 패널 열림
|
||||
- **회색 배경 확인** (스크롤 영역)
|
||||
|
||||
5. **스크롤 테스트**
|
||||
- 마우스 휠로 스크롤
|
||||
- 또는 스크롤바 드래그
|
||||
- **빨간 박스** → 중간 지점
|
||||
- **파란 박스** → 맨 아래 (스크롤 성공!)
|
||||
|
||||
## 스크롤이 여전히 안 되는 경우
|
||||
|
||||
### 체크리스트
|
||||
|
||||
1. ✅ **브라우저 캐시 완전 삭제**
|
||||
```
|
||||
F12 > Network 탭 > "Disable cache" 체크
|
||||
```
|
||||
|
||||
2. ✅ **개발자 도구로 HTML 구조 확인**
|
||||
```
|
||||
F12 > Elements 탭
|
||||
속성 패널의 div 찾기
|
||||
→ "overflow-y: scroll" 스타일 확인
|
||||
```
|
||||
|
||||
3. ✅ **콘솔 에러 확인**
|
||||
```
|
||||
F12 > Console 탭
|
||||
에러 메시지 확인
|
||||
```
|
||||
|
||||
4. ✅ **브라우저 호환성**
|
||||
- Chrome/Edge: 권장
|
||||
- Firefox: 지원
|
||||
- Safari: 일부 스타일 이슈 가능
|
||||
|
||||
### 디버깅 가이드
|
||||
|
||||
**단계 1: HTML 구조 확인**
|
||||
```html
|
||||
<!-- 올바른 구조 -->
|
||||
<div class="flex h-full w-full flex-col"> <!-- PropertiesPanel -->
|
||||
<div class="flex h-16 shrink-0..."> <!-- 헤더 -->
|
||||
<div class="flex-1 overflow-y-scroll..."> <!-- 스크롤 영역 -->
|
||||
<div class="min-h-full space-y-4 p-4"> <!-- 속성 컴포넌트 -->
|
||||
<!-- 긴 컨텐츠 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**단계 2: CSS 스타일 확인**
|
||||
```css
|
||||
/* 스크롤 영역에 있어야 할 스타일 */
|
||||
overflow-y: scroll;
|
||||
max-height: calc(100vh - 64px);
|
||||
flex: 1 1 0%;
|
||||
```
|
||||
|
||||
**단계 3: 컨텐츠 높이 확인**
|
||||
```
|
||||
스크롤이 생기려면:
|
||||
컨텐츠 높이 > 컨테이너 높이
|
||||
```
|
||||
|
||||
## 시각적 표시
|
||||
|
||||
현재 테스트용으로 추가된 표시들:
|
||||
|
||||
1. **노란색 박스** (맨 위)
|
||||
- "📏 스크롤 테스트: 이 패널은 스크롤 가능해야 합니다"
|
||||
|
||||
2. **회색 배경** (전체 스크롤 영역)
|
||||
- `bg-gray-50` 클래스
|
||||
|
||||
3. **빨간색 박스** (중간)
|
||||
- "🚨 스크롤 테스트: 이 빨간 박스가 보이면 스크롤이 작동하는 것입니다!"
|
||||
|
||||
4. **20개 테스트 항목** (중간 ~ 아래)
|
||||
- "테스트 항목 1" ~ "테스트 항목 20"
|
||||
|
||||
5. **파란색 박스** (맨 아래)
|
||||
- "🎉 맨 아래 도착! 이 파란 박스가 보이면 스크롤이 완벽하게 작동합니다!"
|
||||
|
||||
## 제거할 테스트 코드
|
||||
|
||||
스크롤이 확인되면 다음 코드를 제거하세요:
|
||||
|
||||
### TableSourceProperties.tsx
|
||||
```tsx
|
||||
// 제거할 부분 1 (줄 172-174)
|
||||
<div className="rounded bg-yellow-50 p-2 text-xs text-yellow-700">
|
||||
📏 스크롤 테스트: 이 패널은 스크롤 가능해야 합니다
|
||||
</div>
|
||||
|
||||
// 제거할 부분 2 (줄 340-357)
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-red-50 p-4 text-red-700">
|
||||
{/* ... */}
|
||||
</div>
|
||||
{[...Array(20)].map((_, i) => (/* ... */))}
|
||||
<div className="rounded bg-blue-50 p-4 text-blue-700">
|
||||
{/* ... */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### PropertiesPanel.tsx
|
||||
```tsx
|
||||
// bg-gray-50 제거 (줄 47)
|
||||
// 변경 전: className="flex-1 overflow-y-scroll bg-gray-50"
|
||||
// 변경 후: className="flex-1 overflow-y-scroll"
|
||||
```
|
||||
|
||||
## 핵심 원리
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ FlowEditor (h-full) │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ PropertiesPanel (h-full) │ │
|
||||
│ │ ┌─────────────────────────┐ │ │
|
||||
│ │ │ 헤더 (h-16, shrink-0) │ │ │ ← 고정 64px
|
||||
│ │ └─────────────────────────┘ │ │
|
||||
│ │ ┌─────────────────────────┐ │ │
|
||||
│ │ │ 스크롤 영역 │ │ │
|
||||
│ │ │ (flex-1, overflow-y) │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ↓ 컨텐츠가 넘치면 │ │ │
|
||||
│ │ │ ↓ 스크롤바 생성! │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ └─────────────────────────┘ │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
└─────────────────────────────────┘
|
||||
|
||||
flex-1 = 남은 공간을 모두 차지
|
||||
overflow-y: scroll = 세로 스크롤 강제 표시
|
||||
maxHeight = 넘칠 경우를 대비한 최대 높이
|
||||
```
|
||||
|
||||
## 마지막 체크포인트
|
||||
|
||||
스크롤이 작동하는지 확인하는 3가지 방법:
|
||||
|
||||
1. ✅ **마우스 휠**: 속성 패널 위에서 휠 스크롤
|
||||
2. ✅ **스크롤바**: 우측에 스크롤바가 보이면 드래그
|
||||
3. ✅ **키보드**: Page Up/Down 키 또는 방향키
|
||||
|
||||
하나라도 작동하면 성공입니다!
|
||||
|
||||
|
|
@ -0,0 +1,500 @@
|
|||
# 제어관리 데이터 소스 확장 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
제어관리(플로우) 실행 시 사용할 수 있는 데이터 소스가 확장되었습니다. 이제 **폼 데이터**, **테이블 선택 항목**, **테이블 전체 데이터**, **플로우 선택 항목**, **플로우 스텝 전체 데이터** 등 다양한 소스에서 데이터를 가져와 제어를 실행할 수 있습니다.
|
||||
|
||||
## 지원 데이터 소스
|
||||
|
||||
### 1. `form` - 폼 데이터
|
||||
- **설명**: 현재 화면의 폼 입력값을 사용합니다.
|
||||
- **사용 시나리오**: 단일 레코드 생성/수정 시
|
||||
- **데이터 형태**: 단일 객체
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: "홍길동",
|
||||
age: 30,
|
||||
email: "test@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `table-selection` - 테이블 선택 항목
|
||||
- **설명**: 테이블에서 사용자가 선택한 행의 데이터를 사용합니다.
|
||||
- **사용 시나리오**: 선택된 항목에 대한 일괄 처리
|
||||
- **데이터 형태**: 배열 (선택된 행들)
|
||||
|
||||
```typescript
|
||||
[
|
||||
{ id: 1, name: "항목1", status: "대기" },
|
||||
{ id: 2, name: "항목2", status: "대기" }
|
||||
]
|
||||
```
|
||||
|
||||
### 3. `table-all` - 테이블 전체 데이터 🆕
|
||||
- **설명**: 테이블의 **모든 데이터**를 사용합니다 (페이징 무관).
|
||||
- **사용 시나리오**:
|
||||
- 전체 데이터에 대한 일괄 처리
|
||||
- 통계/집계 작업
|
||||
- 대량 데이터 마이그레이션
|
||||
- **데이터 형태**: 배열 (전체 행)
|
||||
- **주의사항**: 데이터가 많을 경우 성능 이슈가 있을 수 있습니다.
|
||||
|
||||
```typescript
|
||||
[
|
||||
{ id: 1, name: "항목1", status: "대기" },
|
||||
{ id: 2, name: "항목2", status: "진행중" },
|
||||
{ id: 3, name: "항목3", status: "완료" },
|
||||
// ... 수천 개의 행
|
||||
]
|
||||
```
|
||||
|
||||
### 4. `flow-selection` - 플로우 선택 항목
|
||||
- **설명**: 플로우 위젯에서 사용자가 선택한 데이터를 사용합니다.
|
||||
- **사용 시나리오**: 플로우 단계별로 선택된 항목 처리
|
||||
- **데이터 형태**: 배열 (선택된 행들)
|
||||
|
||||
```typescript
|
||||
[
|
||||
{ id: 10, taskName: "작업1", stepId: 2 },
|
||||
{ id: 11, taskName: "작업2", stepId: 2 }
|
||||
]
|
||||
```
|
||||
|
||||
### 5. `flow-step-all` - 플로우 스텝 전체 데이터 🆕
|
||||
- **설명**: 현재 선택된 플로우 단계의 **모든 데이터**를 사용합니다.
|
||||
- **사용 시나리오**:
|
||||
- 특정 단계의 모든 항목 일괄 처리
|
||||
- 단계별 완료율 계산
|
||||
- 단계 이동 시 전체 데이터 마이그레이션
|
||||
- **데이터 형태**: 배열 (해당 스텝의 전체 행)
|
||||
|
||||
```typescript
|
||||
[
|
||||
{ id: 10, taskName: "작업1", stepId: 2, assignee: "홍길동" },
|
||||
{ id: 11, taskName: "작업2", stepId: 2, assignee: "김철수" },
|
||||
{ id: 12, taskName: "작업3", stepId: 2, assignee: "이영희" },
|
||||
// ... 해당 스텝의 모든 데이터
|
||||
]
|
||||
```
|
||||
|
||||
### 6. `both` - 폼 + 테이블 선택
|
||||
- **설명**: 폼 데이터와 테이블 선택 항목을 결합하여 사용합니다.
|
||||
- **사용 시나리오**: 폼의 공통 정보 + 개별 항목 처리
|
||||
- **데이터 형태**: 배열 (폼 데이터 + 선택된 행들)
|
||||
|
||||
```typescript
|
||||
[
|
||||
{ name: "홍길동", age: 30 }, // 폼 데이터
|
||||
{ id: 1, name: "항목1", status: "대기" },
|
||||
{ id: 2, name: "항목2", status: "대기" }
|
||||
]
|
||||
```
|
||||
|
||||
### 7. `all-sources` - 모든 소스 결합 🆕
|
||||
- **설명**: 폼, 테이블 전체, 플로우 등 **모든 소스의 데이터를 결합**하여 사용합니다.
|
||||
- **사용 시나리오**:
|
||||
- 복잡한 데이터 통합 작업
|
||||
- 다중 소스 동기화
|
||||
- 전체 시스템 상태 업데이트
|
||||
- **데이터 형태**: 배열 (모든 소스의 데이터 병합)
|
||||
- **주의사항**: 매우 많은 데이터가 전달될 수 있으므로 신중히 사용하세요.
|
||||
|
||||
```typescript
|
||||
[
|
||||
{ name: "홍길동", age: 30 }, // 폼 데이터
|
||||
{ id: 1, name: "테이블1" }, // 테이블 선택
|
||||
{ id: 2, name: "테이블2" }, // 테이블 선택
|
||||
{ id: 3, name: "테이블3" }, // 테이블 전체
|
||||
{ id: 10, taskName: "작업1" }, // 플로우 선택
|
||||
// ... 모든 소스의 데이터
|
||||
]
|
||||
```
|
||||
|
||||
## 설정 방법
|
||||
|
||||
### 1. 버튼 상세 설정에서 데이터 소스 선택
|
||||
|
||||
1. 화면 디자이너에서 버튼 선택
|
||||
2. 우측 패널 > **상세 설정** 탭
|
||||
3. **제어관리 활성화** 체크
|
||||
4. **제어 데이터 소스** 드롭다운에서 원하는 소스 선택
|
||||
|
||||
### 2. 데이터 소스 옵션
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 제어 데이터 소스 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 📄 폼 데이터 │
|
||||
│ 📊 테이블 선택 항목 │
|
||||
│ 📊 테이블 전체 데이터 🆕 │
|
||||
│ 🔄 플로우 선택 항목 │
|
||||
│ 🔄 플로우 스텝 전체 데이터 🆕 │
|
||||
│ 📋 폼 + 테이블 선택 │
|
||||
│ 🌐 모든 소스 결합 🆕 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 실제 사용 예시
|
||||
|
||||
### 예시 1: 테이블 전체 데이터로 일괄 상태 업데이트
|
||||
|
||||
```typescript
|
||||
// 제어 설정
|
||||
{
|
||||
controlDataSource: "table-all",
|
||||
flowConfig: {
|
||||
flowId: 10,
|
||||
flowName: "전체 항목 승인 처리",
|
||||
executionTiming: "replace"
|
||||
}
|
||||
}
|
||||
|
||||
// 실행 시 전달되는 데이터
|
||||
{
|
||||
buttonId: "btn_approve_all",
|
||||
sourceData: [
|
||||
{ id: 1, name: "항목1", status: "대기" },
|
||||
{ id: 2, name: "항목2", status: "대기" },
|
||||
{ id: 3, name: "항목3", status: "대기" },
|
||||
// ... 테이블의 모든 행 (1000개)
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 2: 플로우 스텝 전체를 다음 단계로 이동
|
||||
|
||||
```typescript
|
||||
// 제어 설정
|
||||
{
|
||||
controlDataSource: "flow-step-all",
|
||||
flowConfig: {
|
||||
flowId: 15,
|
||||
flowName: "단계 일괄 이동",
|
||||
executionTiming: "replace"
|
||||
}
|
||||
}
|
||||
|
||||
// 실행 시 전달되는 데이터
|
||||
{
|
||||
buttonId: "btn_move_all",
|
||||
flowStepId: 2,
|
||||
sourceData: [
|
||||
{ id: 10, taskName: "작업1", stepId: 2 },
|
||||
{ id: 11, taskName: "작업2", stepId: 2 },
|
||||
{ id: 12, taskName: "작업3", stepId: 2 },
|
||||
// ... 해당 스텝의 모든 데이터
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 예시 3: 선택된 항목만 처리
|
||||
|
||||
```typescript
|
||||
// 제어 설정
|
||||
{
|
||||
controlDataSource: "table-selection",
|
||||
flowConfig: {
|
||||
flowId: 5,
|
||||
flowName: "선택 항목 승인",
|
||||
executionTiming: "replace"
|
||||
}
|
||||
}
|
||||
|
||||
// 실행 시 전달되는 데이터 (사용자가 2개 선택한 경우)
|
||||
{
|
||||
buttonId: "btn_approve_selected",
|
||||
sourceData: [
|
||||
{ id: 1, name: "항목1", status: "대기" },
|
||||
{ id: 5, name: "항목5", status: "대기" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 데이터 로딩 방식
|
||||
|
||||
### 자동 로딩 vs 수동 로딩
|
||||
|
||||
1. **테이블 선택 항목** (`table-selection`)
|
||||
- ✅ 자동 로딩: 사용자가 이미 선택한 데이터 사용
|
||||
- 별도 로딩 불필요
|
||||
|
||||
2. **테이블 전체 데이터** (`table-all`)
|
||||
- ⚡ 지연 로딩: 버튼 클릭 시 필요한 경우만 로드
|
||||
- 부모 컴포넌트에서 `onRequestTableAllData` 콜백 제공 필요
|
||||
|
||||
3. **플로우 스텝 전체 데이터** (`flow-step-all`)
|
||||
- ⚡ 지연 로딩: 버튼 클릭 시 필요한 경우만 로드
|
||||
- 부모 컴포넌트에서 `onRequestFlowStepAllData` 콜백 제공 필요
|
||||
|
||||
### 부모 컴포넌트 구현 예시
|
||||
|
||||
```tsx
|
||||
<OptimizedButtonComponent
|
||||
component={buttonComponent}
|
||||
selectedRowsData={selectedRowsData}
|
||||
|
||||
// 테이블 전체 데이터 로드 콜백
|
||||
onRequestTableAllData={async () => {
|
||||
const response = await fetch(`/api/data/table/${tableId}?all=true`);
|
||||
const data = await response.json();
|
||||
return data.records;
|
||||
}}
|
||||
|
||||
// 플로우 스텝 전체 데이터 로드 콜백
|
||||
onRequestFlowStepAllData={async (stepId) => {
|
||||
const response = await fetch(`/api/flow/step/${stepId}/all-data`);
|
||||
const data = await response.json();
|
||||
return data.records;
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 성능 고려사항
|
||||
|
||||
### 1. 대량 데이터 처리
|
||||
|
||||
- **테이블 전체 데이터**: 수천 개의 행이 있을 경우 메모리 및 네트워크 부담
|
||||
- **해결 방법**:
|
||||
- 배치 처리 사용
|
||||
- 페이징 처리
|
||||
- 서버 사이드 처리
|
||||
|
||||
### 2. 로딩 시간
|
||||
|
||||
```typescript
|
||||
// ❌ 나쁜 예: 모든 데이터를 항상 미리 로드
|
||||
useEffect(() => {
|
||||
loadTableAllData(); // 버튼을 누르지 않아도 로드됨
|
||||
}, []);
|
||||
|
||||
// ✅ 좋은 예: 필요할 때만 로드 (지연 로딩)
|
||||
const onRequestTableAllData = async () => {
|
||||
return await loadTableAllData(); // 버튼 클릭 시에만 로드
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 캐싱
|
||||
|
||||
```typescript
|
||||
// 전체 데이터를 캐싱하여 재사용
|
||||
const [cachedTableAllData, setCachedTableAllData] = useState<any[]>([]);
|
||||
|
||||
const onRequestTableAllData = async () => {
|
||||
if (cachedTableAllData.length > 0) {
|
||||
console.log("캐시된 데이터 사용");
|
||||
return cachedTableAllData;
|
||||
}
|
||||
|
||||
const data = await loadTableAllData();
|
||||
setCachedTableAllData(data);
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
## 노드 플로우에서 데이터 사용
|
||||
|
||||
### contextData 구조
|
||||
|
||||
노드 플로우 실행 시 전달되는 `contextData`는 다음과 같은 구조를 가집니다:
|
||||
|
||||
```typescript
|
||||
{
|
||||
buttonId: "btn_approve",
|
||||
screenId: 123,
|
||||
companyCode: "DEFAULT",
|
||||
userId: "user001",
|
||||
controlDataSource: "table-all",
|
||||
|
||||
// 공통 데이터
|
||||
formData: { name: "홍길동" },
|
||||
|
||||
// 소스별 데이터
|
||||
selectedRowsData: [...], // table-selection
|
||||
tableAllData: [...], // table-all
|
||||
flowSelectedData: [...], // flow-selection
|
||||
flowStepAllData: [...], // flow-step-all
|
||||
flowStepId: 2, // 현재 플로우 스텝 ID
|
||||
|
||||
// 통합 데이터 (모든 노드에서 사용 가능)
|
||||
sourceData: [...] // controlDataSource에 따라 결정된 데이터
|
||||
}
|
||||
```
|
||||
|
||||
### 노드에서 데이터 접근
|
||||
|
||||
```typescript
|
||||
// External Call 노드
|
||||
{
|
||||
nodeType: "external-call",
|
||||
config: {
|
||||
url: "https://api.example.com/bulk-approve",
|
||||
method: "POST",
|
||||
body: {
|
||||
// sourceData를 사용하여 데이터 전달
|
||||
items: "{{sourceData}}",
|
||||
approver: "{{formData.approver}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DDL 노드
|
||||
{
|
||||
nodeType: "ddl",
|
||||
config: {
|
||||
sql: `
|
||||
UPDATE tasks
|
||||
SET status = 'approved'
|
||||
WHERE id IN ({{sourceData.map(d => d.id).join(',')}})
|
||||
`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 디버깅 및 로그
|
||||
|
||||
### 콘솔 로그 확인
|
||||
|
||||
버튼 클릭 시 다음과 같은 로그가 출력됩니다:
|
||||
|
||||
```
|
||||
📊 데이터 소스 모드: {
|
||||
controlDataSource: "table-all",
|
||||
hasFormData: true,
|
||||
hasTableSelection: false,
|
||||
hasFlowSelection: false
|
||||
}
|
||||
|
||||
📊 테이블 전체 데이터 로드 중...
|
||||
✅ 테이블 전체 데이터 1,234건 로드 완료
|
||||
|
||||
🚀 노드 플로우 실행 시작: {
|
||||
flowId: 10,
|
||||
flowName: "전체 항목 승인",
|
||||
timing: "replace",
|
||||
sourceDataCount: 1234
|
||||
}
|
||||
```
|
||||
|
||||
### 에러 처리
|
||||
|
||||
```typescript
|
||||
// 데이터 로드 실패 시
|
||||
❌ 테이블 전체 데이터 로드 실패: Network error
|
||||
🔔 Toast: "테이블 전체 데이터를 불러오지 못했습니다"
|
||||
|
||||
// 플로우 실행 실패 시
|
||||
❌ 플로우 실행 실패: 조건 불만족
|
||||
🔔 Toast: "테이블 전체 조건 불만족: status === 'pending' (실제값: approved)"
|
||||
```
|
||||
|
||||
## 마이그레이션 가이드
|
||||
|
||||
### 기존 설정에서 업그레이드
|
||||
|
||||
기존에 `table-selection`을 사용하던 버튼을 `table-all`로 변경하는 경우:
|
||||
|
||||
1. **버튼 설정 변경**: `table-selection` → `table-all`
|
||||
2. **부모 컴포넌트 업데이트**: `onRequestTableAllData` 콜백 추가
|
||||
3. **노드 플로우 업데이트**: 대량 데이터 처리 로직 추가
|
||||
4. **테스트**: 소량 데이터로 먼저 테스트 후 전체 적용
|
||||
|
||||
### 하위 호환성
|
||||
|
||||
- ✅ 기존 `form`, `table-selection`, `both` 설정은 그대로 동작
|
||||
- ✅ 새로운 데이터 소스는 선택적으로 사용 가능
|
||||
- ✅ 기존 노드 플로우는 수정 없이 동작
|
||||
|
||||
## 베스트 프랙티스
|
||||
|
||||
### 1. 적절한 데이터 소스 선택
|
||||
|
||||
| 시나리오 | 권장 데이터 소스 |
|
||||
|---------|----------------|
|
||||
| 단일 레코드 생성/수정 | `form` |
|
||||
| 선택된 항목 일괄 처리 | `table-selection` |
|
||||
| 전체 항목 일괄 처리 | `table-all` |
|
||||
| 플로우 단계별 선택 처리 | `flow-selection` |
|
||||
| 플로우 단계 전체 이동 | `flow-step-all` |
|
||||
| 복잡한 통합 작업 | `all-sources` |
|
||||
|
||||
### 2. 성능 최적화
|
||||
|
||||
```typescript
|
||||
// ✅ 좋은 예: 배치 처리
|
||||
const batchSize = 100;
|
||||
for (let i = 0; i < sourceData.length; i += batchSize) {
|
||||
const batch = sourceData.slice(i, i + batchSize);
|
||||
await processBatch(batch);
|
||||
}
|
||||
|
||||
// ❌ 나쁜 예: 동기 처리
|
||||
for (const item of sourceData) {
|
||||
await processItem(item); // 1000개면 1000번 API 호출
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 사용자 피드백
|
||||
|
||||
```typescript
|
||||
// 대량 데이터 처리 시 진행률 표시
|
||||
toast.info(`${processed}/${total} 항목 처리 중...`, {
|
||||
id: "batch-progress"
|
||||
});
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### Q1: 테이블 전체 데이터가 로드되지 않습니다
|
||||
|
||||
**A**: 부모 컴포넌트에 `onRequestTableAllData` 콜백이 구현되어 있는지 확인하세요.
|
||||
|
||||
```tsx
|
||||
// InteractiveScreenViewer.tsx 확인
|
||||
<OptimizedButtonComponent
|
||||
onRequestTableAllData={async () => {
|
||||
// 이 함수가 구현되어 있어야 함
|
||||
return await fetchAllData();
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Q2: 플로우 스텝 전체 데이터가 빈 배열입니다
|
||||
|
||||
**A**:
|
||||
1. 플로우 스텝이 선택되어 있는지 확인
|
||||
2. `flowSelectedStepId`가 올바르게 전달되는지 확인
|
||||
3. `onRequestFlowStepAllData` 콜백이 구현되어 있는지 확인
|
||||
|
||||
### Q3: 데이터가 너무 많아 브라우저가 느려집니다
|
||||
|
||||
**A**:
|
||||
1. 서버 사이드 처리 고려
|
||||
2. 배치 처리 사용
|
||||
3. 페이징 적용
|
||||
4. `table-selection` 사용 권장 (전체 대신 선택)
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 타입 정의
|
||||
- `frontend/types/control-management.ts` - `ControlDataSource` 타입
|
||||
|
||||
### 핵심 로직
|
||||
- `frontend/lib/utils/nodeFlowButtonExecutor.ts` - 데이터 준비 및 전달
|
||||
- `frontend/components/screen/OptimizedButtonComponent.tsx` - 버튼 컴포넌트
|
||||
|
||||
### UI 설정
|
||||
- `frontend/components/screen/config-panels/ButtonDataflowConfigPanel.tsx` - 설정 패널
|
||||
|
||||
### 서비스
|
||||
- `frontend/lib/services/optimizedButtonDataflowService.ts` - 데이터 검증 및 처리
|
||||
|
||||
## 업데이트 이력
|
||||
|
||||
- **2025-01-24**: 초기 문서 작성
|
||||
- `table-all` 데이터 소스 추가
|
||||
- `flow-step-all` 데이터 소스 추가
|
||||
- `all-sources` 데이터 소스 추가
|
||||
- 지연 로딩 메커니즘 구현
|
||||
|
||||
Loading…
Reference in New Issue