feature/screen-management #148
|
|
@ -0,0 +1,209 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
description: API 요청 시 항상 전용 API 클라이언트를 사용하도록 강제하는 규칙
|
||||
---
|
||||
|
||||
# API 클라이언트 사용 규칙
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**절대 `fetch`를 직접 사용하지 않고, 반드시 전용 API 클라이언트를 사용해야 합니다.**
|
||||
|
||||
## 이유
|
||||
|
||||
1. **환경별 URL 자동 처리**: 프로덕션(`v1.vexplor.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청
|
||||
2. **일관된 에러 처리**: 모든 API 호출에서 동일한 에러 핸들링
|
||||
3. **인증 토큰 자동 포함**: Authorization 헤더 자동 추가
|
||||
4. **유지보수성**: API 변경 시 한 곳에서만 수정
|
||||
|
||||
## API 클라이언트 위치
|
||||
|
||||
```
|
||||
frontend/lib/api/
|
||||
├── client.ts # Axios 기반 공통 클라이언트
|
||||
├── flow.ts # 플로우 관리 API
|
||||
├── dashboard.ts # 대시보드 API
|
||||
├── mail.ts # 메일 API
|
||||
├── externalCall.ts # 외부 호출 API
|
||||
├── company.ts # 회사 관리 API
|
||||
└── file.ts # 파일 업로드/다운로드 API
|
||||
```
|
||||
|
||||
## 올바른 사용법
|
||||
|
||||
### ❌ 잘못된 방법 (절대 사용 금지)
|
||||
|
||||
```typescript
|
||||
// 직접 fetch 사용 - 환경별 URL이 자동 처리되지 않음
|
||||
const response = await fetch("/api/flow/definitions/29/steps");
|
||||
const data = await response.json();
|
||||
|
||||
// 상대 경로 - 프로덕션에서 잘못된 도메인으로 요청
|
||||
const response = await fetch(`/api/flow/${flowId}/steps`);
|
||||
```
|
||||
|
||||
### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
// 1. API 클라이언트 함수 import
|
||||
import { getFlowSteps } from "@/lib/api/flow";
|
||||
|
||||
// 2. 함수 호출
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
if (stepsResponse.success && stepsResponse.data) {
|
||||
setSteps(stepsResponse.data);
|
||||
}
|
||||
```
|
||||
|
||||
## 주요 API 클라이언트 함수
|
||||
|
||||
### 플로우 관리 ([flow.ts](mdc:frontend/lib/api/flow.ts))
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getFlowDefinitions, // 플로우 목록
|
||||
getFlowById, // 플로우 상세
|
||||
createFlowDefinition, // 플로우 생성
|
||||
updateFlowDefinition, // 플로우 수정
|
||||
deleteFlowDefinition, // 플로우 삭제
|
||||
getFlowSteps, // 스텝 목록 ⭐
|
||||
createFlowStep, // 스텝 생성
|
||||
updateFlowStep, // 스텝 수정
|
||||
deleteFlowStep, // 스텝 삭제
|
||||
getFlowConnections, // 연결 목록 ⭐
|
||||
createFlowConnection, // 연결 생성
|
||||
deleteFlowConnection, // 연결 삭제
|
||||
getStepDataCount, // 스텝 데이터 카운트
|
||||
getStepDataList, // 스텝 데이터 목록
|
||||
getAllStepCounts, // 모든 스텝 카운트
|
||||
moveData, // 데이터 이동
|
||||
moveBatchData, // 배치 데이터 이동
|
||||
getAuditLogs, // 오딧 로그
|
||||
} from "@/lib/api/flow";
|
||||
```
|
||||
|
||||
### Axios 클라이언트 ([client.ts](mdc:frontend/lib/api/client.ts))
|
||||
|
||||
```typescript
|
||||
import apiClient from "@/lib/api/client";
|
||||
|
||||
// GET 요청
|
||||
const response = await apiClient.get("/api/endpoint");
|
||||
|
||||
// POST 요청
|
||||
const response = await apiClient.post("/api/endpoint", { data });
|
||||
|
||||
// PUT 요청
|
||||
const response = await apiClient.put("/api/endpoint", { data });
|
||||
|
||||
// DELETE 요청
|
||||
const response = await apiClient.delete("/api/endpoint");
|
||||
```
|
||||
|
||||
## 새로운 API 함수 추가 가이드
|
||||
|
||||
기존 API 클라이언트에 함수가 없는 경우:
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/yourModule.ts
|
||||
|
||||
// 1. API URL 동적 설정 (필수)
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
// 프로덕션: v1.vexplor.com → api.vexplor.com
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return "https://api.vexplor.com/api";
|
||||
}
|
||||
|
||||
// 로컬 개발
|
||||
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
|
||||
return "http://localhost:8080/api";
|
||||
}
|
||||
}
|
||||
|
||||
return "/api";
|
||||
};
|
||||
|
||||
const API_BASE = getApiBaseUrl();
|
||||
|
||||
// 2. API 함수 작성
|
||||
export async function getYourData(id: number): Promise<ApiResponse<YourType>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/your-endpoint/${id}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 환경별 URL 매핑
|
||||
|
||||
API 클라이언트는 자동으로 환경을 감지합니다:
|
||||
|
||||
| 현재 호스트 | 백엔드 API URL |
|
||||
| ---------------- | ----------------------------- |
|
||||
| `v1.vexplor.com` | `https://api.vexplor.com/api` |
|
||||
| `localhost:9771` | `http://localhost:8080/api` |
|
||||
| `localhost:3000` | `http://localhost:8080/api` |
|
||||
|
||||
## 체크리스트
|
||||
|
||||
코드 작성 시 다음을 확인하세요:
|
||||
|
||||
- [ ] `fetch('/api/...')` 직접 사용하지 않음
|
||||
- [ ] 적절한 API 클라이언트 함수를 import 함
|
||||
- [ ] API 응답의 `success` 필드를 체크함
|
||||
- [ ] 에러 처리를 구현함
|
||||
- [ ] 새로운 API가 필요하면 `lib/api/` 에 함수 추가
|
||||
|
||||
## 예외 상황
|
||||
|
||||
다음 경우에만 `fetch`를 직접 사용할 수 있습니다:
|
||||
|
||||
1. **외부 서비스 호출**: 다른 도메인의 API 호출 시
|
||||
2. **특수한 헤더가 필요한 경우**: FormData, Blob 등
|
||||
|
||||
이 경우에도 가능하면 전용 API 클라이언트 함수로 래핑하세요.
|
||||
|
||||
## 실제 적용 예시
|
||||
|
||||
### 플로우 위젯 ([FlowWidget.tsx](mdc:frontend/components/screen/widgets/FlowWidget.tsx))
|
||||
|
||||
```typescript
|
||||
// ❌ 이전 코드
|
||||
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
||||
const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`);
|
||||
|
||||
// ✅ 수정된 코드
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
const connectionsResponse = await getFlowConnections(flowId);
|
||||
```
|
||||
|
||||
### 플로우 가시성 패널 ([FlowVisibilityConfigPanel.tsx](mdc:frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx))
|
||||
|
||||
```typescript
|
||||
// ❌ 이전 코드
|
||||
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
||||
|
||||
// ✅ 수정된 코드
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
```
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [API 클라이언트 공통 설정](mdc:frontend/lib/api/client.ts)
|
||||
- [플로우 API 클라이언트](mdc:frontend/lib/api/flow.ts)
|
||||
- [API URL 유틸리티](mdc:frontend/lib/utils/apiUrl.ts)
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
# 고정 헤더 테이블 표준 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
스크롤 가능한 테이블에서 헤더를 상단에 고정하는 표준 구조입니다.
|
||||
플로우 위젯의 스텝 데이터 리스트 테이블을 참조 기준으로 합니다.
|
||||
|
||||
## 필수 구조
|
||||
|
||||
### 1. 기본 HTML 구조
|
||||
|
||||
```tsx
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더 1
|
||||
</TableHead>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더 2
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{/* 데이터 행들 */}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. 필수 클래스 설명
|
||||
|
||||
#### 스크롤 컨테이너 (외부 div)
|
||||
|
||||
```tsx
|
||||
className="relative overflow-auto"
|
||||
style={{ height: "450px" }}
|
||||
```
|
||||
|
||||
**필수 요소:**
|
||||
|
||||
- `relative`: sticky positioning의 기준점
|
||||
- `overflow-auto`: 스크롤 활성화
|
||||
- `height`: 고정 높이 (인라인 스타일 또는 Tailwind 클래스)
|
||||
|
||||
#### Table 컴포넌트
|
||||
|
||||
```tsx
|
||||
<Table noWrapper>
|
||||
```
|
||||
|
||||
**필수 props:**
|
||||
|
||||
- `noWrapper`: Table 컴포넌트의 내부 wrapper 제거 (매우 중요!)
|
||||
- 이것이 없으면 sticky header가 작동하지 않음
|
||||
|
||||
#### TableHead (헤더 셀)
|
||||
|
||||
```tsx
|
||||
className =
|
||||
"bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
||||
```
|
||||
|
||||
**필수 클래스:**
|
||||
|
||||
- `bg-background`: 배경색 (스크롤 시 데이터가 보이지 않도록)
|
||||
- `sticky top-0`: 상단 고정
|
||||
- `z-10`: 다른 요소 위에 표시
|
||||
- `border-b`: 하단 테두리
|
||||
- `shadow-[0_1px_0_0_rgb(0,0,0,0.1)]`: 얇은 그림자 (헤더와 본문 구분)
|
||||
|
||||
### 3. 왼쪽 열 고정 (체크박스 등)
|
||||
|
||||
첫 번째 열도 고정하려면:
|
||||
|
||||
```tsx
|
||||
<TableHead className="bg-background sticky top-0 left-0 z-20 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
<Checkbox />
|
||||
</TableHead>
|
||||
```
|
||||
|
||||
**z-index 규칙:**
|
||||
|
||||
- 왼쪽+상단 고정: `z-20`
|
||||
- 상단만 고정: `z-10`
|
||||
- 왼쪽만 고정: `z-10`
|
||||
- 일반 셀: z-index 없음
|
||||
|
||||
### 4. 완전한 예제 (체크박스 포함)
|
||||
|
||||
```tsx
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 왼쪽 고정 체크박스 열 */}
|
||||
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
<Checkbox checked={allSelected} onCheckedChange={handleSelectAll} />
|
||||
</TableHead>
|
||||
|
||||
{/* 일반 헤더 열들 */}
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={col}
|
||||
className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{data.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
{/* 왼쪽 고정 체크박스 */}
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(index)}
|
||||
onCheckedChange={() => toggleRow(index)}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 데이터 셀들 */}
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col} className="border-b px-3 py-2">
|
||||
{row[col]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 반응형 대응
|
||||
|
||||
### 모바일: 카드 뷰
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 모바일: 카드 뷰 */
|
||||
}
|
||||
<div className="overflow-y-auto sm:hidden" style={{ height: "450px" }}>
|
||||
<div className="space-y-2 p-3">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="bg-card rounded-md border p-3">
|
||||
{/* 카드 내용 */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
{
|
||||
/* 데스크톱: 테이블 뷰 */
|
||||
}
|
||||
<div
|
||||
className="relative hidden overflow-auto sm:block"
|
||||
style={{ height: "450px" }}
|
||||
>
|
||||
<Table noWrapper>{/* 위의 테이블 구조 */}</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
## 자주하는 실수
|
||||
|
||||
### ❌ 잘못된 예시
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 1. noWrapper 없음 - sticky 작동 안함 */
|
||||
}
|
||||
<Table>
|
||||
<TableHeader>...</TableHeader>
|
||||
</Table>;
|
||||
|
||||
{
|
||||
/* 2. 배경색 없음 - 스크롤 시 데이터가 보임 */
|
||||
}
|
||||
<TableHead className="sticky top-0">헤더</TableHead>;
|
||||
|
||||
{
|
||||
/* 3. relative 없음 - sticky 기준점 없음 */
|
||||
}
|
||||
<div className="overflow-auto">
|
||||
<Table noWrapper>...</Table>
|
||||
</div>;
|
||||
|
||||
{
|
||||
/* 4. 고정 높이 없음 - 스크롤 발생 안함 */
|
||||
}
|
||||
<div className="relative overflow-auto">
|
||||
<Table noWrapper>...</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
### ✅ 올바른 예시
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 모든 필수 요소 포함 */
|
||||
}
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>...</TableBody>
|
||||
</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
## 높이 설정 가이드
|
||||
|
||||
### 권장 높이값
|
||||
|
||||
- **소형 리스트**: `300px` ~ `400px`
|
||||
- **중형 리스트**: `450px` ~ `600px` (플로우 위젯 기준)
|
||||
- **대형 리스트**: `calc(100vh - 200px)` (화면 높이 기준)
|
||||
|
||||
### 동적 높이 계산
|
||||
|
||||
```tsx
|
||||
// 화면 높이의 60%
|
||||
style={{ height: "60vh" }}
|
||||
|
||||
// 화면 높이 - 헤더/푸터 제외
|
||||
style={{ height: "calc(100vh - 250px)" }}
|
||||
|
||||
// 부모 요소 기준
|
||||
className="h-full overflow-auto"
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 1. 가상 스크롤 (대량 데이터)
|
||||
|
||||
데이터가 1000건 이상인 경우 `react-virtual` 사용 권장:
|
||||
|
||||
```tsx
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: data.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 50, // 행 높이
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 페이지네이션
|
||||
|
||||
대량 데이터는 페이지 단위로 렌더링:
|
||||
|
||||
```tsx
|
||||
const paginatedData = data.slice((page - 1) * pageSize, page * pageSize);
|
||||
```
|
||||
|
||||
## 접근성
|
||||
|
||||
### ARIA 레이블
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="relative overflow-auto"
|
||||
style={{ height: "450px" }}
|
||||
role="region"
|
||||
aria-label="스크롤 가능한 데이터 테이블"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Table noWrapper aria-label="데이터 목록">
|
||||
{/* 테이블 내용 */}
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 키보드 네비게이션
|
||||
|
||||
```tsx
|
||||
<TableRow
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleRowClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 행 내용 */}
|
||||
</TableRow>
|
||||
```
|
||||
|
||||
## 다크 모드 대응
|
||||
|
||||
### 배경색
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 라이트/다크 모드 모두 대응 */
|
||||
}
|
||||
className = "bg-background"; // ✅ 권장
|
||||
|
||||
{
|
||||
/* 고정 색상 - 다크 모드 문제 */
|
||||
}
|
||||
className = "bg-white"; // ❌ 비권장
|
||||
```
|
||||
|
||||
### 그림자
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 다크 모드에서도 보이는 그림자 */
|
||||
}
|
||||
className = "shadow-[0_1px_0_0_hsl(var(--border))]";
|
||||
|
||||
{
|
||||
/* 또는 */
|
||||
}
|
||||
className = "shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
||||
```
|
||||
|
||||
## 참조 파일
|
||||
|
||||
- **구현 예시**: `frontend/components/screen/widgets/FlowWidget.tsx` (line 760-820)
|
||||
- **Table 컴포넌트**: `frontend/components/ui/table.tsx`
|
||||
|
||||
## 체크리스트
|
||||
|
||||
테이블 구현 시 다음을 확인하세요:
|
||||
|
||||
- [ ] 외부 div에 `relative overflow-auto` 적용
|
||||
- [ ] 외부 div에 고정 높이 설정
|
||||
- [ ] `<Table noWrapper>` 사용
|
||||
- [ ] TableHead에 `bg-background sticky top-0 z-10` 적용
|
||||
- [ ] TableHead에 `border-b shadow-[...]` 적용
|
||||
- [ ] 왼쪽 고정 열은 `z-20` 사용
|
||||
- [ ] 모바일 반응형 대응 (카드 뷰)
|
||||
- [ ] 다크 모드 호환 색상 사용
|
||||
|
|
@ -147,6 +147,14 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
flowData.nodes || [],
|
||||
flowData.edges || [],
|
||||
);
|
||||
|
||||
// 🆕 플로우 로드 후 첫 번째 노드 자동 선택
|
||||
if (flowData.nodes && flowData.nodes.length > 0) {
|
||||
const firstNode = flowData.nodes[0];
|
||||
selectNodes([firstNode.id]);
|
||||
setShowPropertiesPanelLocal(true);
|
||||
console.log("✅ 첫 번째 노드 자동 선택:", firstNode.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("플로우 로드 실패:", error);
|
||||
|
|
@ -155,7 +163,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
};
|
||||
|
||||
fetchAndLoadFlow();
|
||||
}, [initialFlowId]);
|
||||
}, [initialFlowId, loadFlow, selectNodes]);
|
||||
|
||||
/**
|
||||
* 노드 선택 변경 핸들러
|
||||
|
|
|
|||
|
|
@ -67,6 +67,19 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable);
|
||||
|
||||
// 내부 DB 컬럼 관련 상태
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
}
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// Combobox 열림 상태 관리
|
||||
const [fieldOpenState, setFieldOpenState] = useState<boolean[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
||||
setTargetTable(data.targetTable);
|
||||
|
|
@ -101,6 +114,18 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
}
|
||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||||
|
||||
// 🔥 내부 DB 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "internal" && targetTable) {
|
||||
loadColumns(targetTable);
|
||||
}
|
||||
}, [targetType, targetTable]);
|
||||
|
||||
// whereConditions 변경 시 fieldOpenState 초기화
|
||||
useEffect(() => {
|
||||
setFieldOpenState(new Array(whereConditions.length).fill(false));
|
||||
}, [whereConditions.length]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
try {
|
||||
setExternalConnectionsLoading(true);
|
||||
|
|
@ -171,6 +196,28 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
}
|
||||
};
|
||||
|
||||
// 🔥 내부 DB 컬럼 로딩
|
||||
const loadColumns = async (tableName: string) => {
|
||||
try {
|
||||
setColumnsLoading(true);
|
||||
const response = await tableTypeApi.getColumns(tableName);
|
||||
if (response && Array.isArray(response)) {
|
||||
const columnInfos: ColumnInfo[] = response.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.columnLabel || col.column_label,
|
||||
dataType: col.dataType || col.data_type || "text",
|
||||
isNullable: col.isNullable !== undefined ? col.isNullable : true,
|
||||
}));
|
||||
setTargetColumns(columnInfos);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 로딩 실패:", error);
|
||||
setTargetColumns([]);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableSelect = (tableName: string) => {
|
||||
const selectedTable = tables.find((t: any) => t.tableName === tableName);
|
||||
const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName;
|
||||
|
|
@ -186,18 +233,22 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
const newConditions = [
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
];
|
||||
setWhereConditions(newConditions);
|
||||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||||
setWhereConditions(newConditions);
|
||||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -639,64 +690,169 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 로딩 상태 */}
|
||||
{targetType === "internal" && targetTable && columnsLoading && (
|
||||
<div className="rounded border border-gray-200 bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
컬럼 정보를 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 미선택 안내 */}
|
||||
{targetType === "internal" && !targetTable && (
|
||||
<div className="rounded border border-dashed border-gray-300 bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
먼저 타겟 테이블을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{whereConditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{whereConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border-2 border-red-200 bg-red-50 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{whereConditions.map((condition, index) => {
|
||||
// 현재 타입에 따라 사용 가능한 컬럼 리스트 결정
|
||||
const availableColumns =
|
||||
targetType === "internal"
|
||||
? targetColumns
|
||||
: targetType === "external"
|
||||
? externalColumns.map((col) => ({
|
||||
columnName: col.column_name,
|
||||
columnLabel: col.column_name,
|
||||
dataType: col.data_type,
|
||||
isNullable: true,
|
||||
}))
|
||||
: [];
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
<Input
|
||||
value={condition.field}
|
||||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
||||
placeholder="조건 필드명"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
return (
|
||||
<div key={index} className="rounded border-2 border-red-200 bg-red-50 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">값</Label>
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{/* 필드 - Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
{availableColumns.length > 0 ? (
|
||||
<Popover
|
||||
open={fieldOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...fieldOpenState];
|
||||
newState[index] = open;
|
||||
setFieldOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={fieldOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{condition.field
|
||||
? (() => {
|
||||
const col = availableColumns.find((c) => c.columnName === condition.field);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-mono">
|
||||
{col?.columnLabel || condition.field}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{col?.dataType}</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={(currentValue) => {
|
||||
handleConditionChange(index, "field", currentValue);
|
||||
const newState = [...fieldOpenState];
|
||||
newState[index] = false;
|
||||
setFieldOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
condition.field === col.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">
|
||||
{col.columnLabel || col.columnName}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.dataType}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
value={condition.field}
|
||||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
||||
placeholder="조건 필드명"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">값</Label>
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border-2 border-dashed border-red-300 bg-red-50 p-4 text-center text-xs text-red-600">
|
||||
|
|
@ -705,7 +861,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
||||
🚨 WHERE 조건 없이 삭제하면 테이블의 모든 데이터가 영구 삭제됩니다!
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ 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";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
|
@ -37,6 +38,7 @@ interface ColumnInfo {
|
|||
columnLabel?: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
columnDefault?: string | null;
|
||||
}
|
||||
|
||||
export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) {
|
||||
|
|
@ -63,6 +65,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
// REST API 소스 노드 연결 여부
|
||||
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||||
|
||||
// Combobox 열림 상태 관리 (필드 매핑)
|
||||
const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState<boolean[]>([]);
|
||||
const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState<boolean[]>([]);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||
|
|
@ -118,6 +124,12 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
}
|
||||
}, [targetType, selectedExternalConnectionId]);
|
||||
|
||||
// fieldMappings 변경 시 Combobox 열림 상태 초기화
|
||||
useEffect(() => {
|
||||
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
}, [fieldMappings.length]);
|
||||
|
||||
// 🔥 외부 테이블 변경 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||
|
|
@ -340,12 +352,27 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
}));
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => {
|
||||
// is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환
|
||||
const isNullableValue = col.is_nullable ?? col.isNullable;
|
||||
let isNullable = true; // 기본값: nullable
|
||||
|
||||
if (typeof isNullableValue === "boolean") {
|
||||
isNullable = isNullableValue;
|
||||
} else if (typeof isNullableValue === "string") {
|
||||
isNullable = isNullableValue.toUpperCase() === "YES" || isNullableValue.toUpperCase() === "TRUE";
|
||||
} else if (typeof isNullableValue === "number") {
|
||||
isNullable = isNullableValue !== 0;
|
||||
}
|
||||
|
||||
return {
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable,
|
||||
columnDefault: col.column_default ?? col.columnDefault ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
setTargetColumns(columnInfo);
|
||||
console.log(`✅ 컬럼 ${columnInfo.length}개 로딩 완료`);
|
||||
|
|
@ -449,12 +476,20 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
|
||||
// Combobox 열림 상태 배열 초기화
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
|
||||
// Combobox 열림 상태 배열도 업데이트
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -1077,35 +1112,87 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
) : (
|
||||
// 일반 소스인 경우: 드롭다운 선택
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||
// 일반 소스인 경우: Combobox 선택
|
||||
<Popover
|
||||
open={mappingSourceFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">
|
||||
연결된 소스 노드가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{mapping.sourceField
|
||||
? (() => {
|
||||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-medium">
|
||||
{field?.label || mapping.sourceField}
|
||||
</span>
|
||||
{field?.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: "소스 필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
필드를 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sourceFields.map((field) => (
|
||||
<CommandItem
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "sourceField", currentValue || null);
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{hasRestAPISource && (
|
||||
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
||||
|
|
@ -1116,43 +1203,134 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
<ArrowRight className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 드롭다운 (🔥 타입별 컬럼 사용) */}
|
||||
{/* 타겟 필드 Combobox (🔥 타입별 컬럼 사용) */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
||||
<Popover
|
||||
open={mappingTargetFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="타겟 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 🔥 내부 DB 컬럼 */}
|
||||
{targetType === "internal" &&
|
||||
targetColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{col.dataType}
|
||||
{!col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingTargetFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{mapping.targetField
|
||||
? (() => {
|
||||
if (targetType === "internal") {
|
||||
const col = targetColumns.find((c) => c.columnName === mapping.targetField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-mono">
|
||||
{col?.columnLabel || mapping.targetField}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{col?.dataType}
|
||||
{col && !col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const col = externalColumns.find(
|
||||
(c) => c.column_name === mapping.targetField,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-mono">
|
||||
{col?.column_name || mapping.targetField}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{col?.data_type}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
: "타겟 필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="타겟 필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{/* 🔥 내부 DB 컬럼 */}
|
||||
{targetType === "internal" &&
|
||||
targetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "targetField", currentValue);
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.targetField === col.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">
|
||||
{col.columnLabel || col.columnName}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{col.dataType}
|
||||
{col.columnDefault ? (
|
||||
<span className="text-blue-600"> 🔵기본값</span>
|
||||
) : (
|
||||
!col.isNullable && <span className="text-red-500"> *필수</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
{/* 🔥 외부 DB 컬럼 */}
|
||||
{targetType === "external" &&
|
||||
externalColumns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.column_name}</span>
|
||||
<span className="text-muted-foreground">{col.data_type}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 🔥 외부 DB 컬럼 */}
|
||||
{targetType === "external" &&
|
||||
externalColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.column_name}
|
||||
value={col.column_name}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "targetField", currentValue);
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.targetField === col.column_name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">{col.column_name}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.data_type}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Trash2, Search } from "lucide-react";
|
||||
import { Plus, Trash2, Search, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
||||
|
|
@ -62,6 +61,9 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
const [referenceColumns, setReferenceColumns] = useState<FieldDefinition[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// Combobox 열림 상태 관리
|
||||
const [whereFieldOpenState, setWhereFieldOpenState] = useState<boolean[]>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "참조 조회");
|
||||
|
|
@ -72,6 +74,11 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
setOutputFields(data.outputFields || []);
|
||||
}, [data]);
|
||||
|
||||
// whereConditions 변경 시 whereFieldOpenState 초기화
|
||||
useEffect(() => {
|
||||
setWhereFieldOpenState(new Array(whereConditions.length).fill(false));
|
||||
}, [whereConditions.length]);
|
||||
|
||||
// 🔍 소스 필드 수집 (업스트림 노드에서)
|
||||
useEffect(() => {
|
||||
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
||||
|
|
@ -187,7 +194,7 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
|
||||
// WHERE 조건 추가
|
||||
const handleAddWhereCondition = () => {
|
||||
setWhereConditions([
|
||||
const newConditions = [
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
|
|
@ -195,11 +202,15 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
value: "",
|
||||
valueType: "static",
|
||||
},
|
||||
]);
|
||||
];
|
||||
setWhereConditions(newConditions);
|
||||
setWhereFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
};
|
||||
|
||||
const handleRemoveWhereCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||||
setWhereConditions(newConditions);
|
||||
setWhereFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
};
|
||||
|
||||
const handleWhereConditionChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -455,23 +466,81 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 필드 - Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "field", value)}
|
||||
<Popover
|
||||
open={whereFieldOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...whereFieldOpenState];
|
||||
newState[index] = open;
|
||||
setWhereFieldOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceColumns.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={whereFieldOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{condition.field
|
||||
? (() => {
|
||||
const field = referenceColumns.find((f) => f.name === condition.field);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate">{field?.label || condition.field}</span>
|
||||
{field?.type && (
|
||||
<span className="text-muted-foreground text-xs">{field.type}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{referenceColumns.map((field) => (
|
||||
<CommandItem
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
onSelect={(currentValue) => {
|
||||
handleWhereConditionChange(index, "field", currentValue);
|
||||
const newState = [...whereFieldOpenState];
|
||||
newState[index] = false;
|
||||
setWhereFieldOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
condition.field === field.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.type && (
|
||||
<span className="text-muted-foreground text-[10px]">{field.type}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,6 +37,7 @@ interface ColumnInfo {
|
|||
columnLabel?: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
columnDefault?: string | null;
|
||||
}
|
||||
|
||||
export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) {
|
||||
|
|
@ -85,6 +86,11 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
// REST API 소스 노드 연결 여부
|
||||
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||||
|
||||
// Combobox 열림 상태 관리
|
||||
const [conflictKeysOpenState, setConflictKeysOpenState] = useState<boolean[]>([]);
|
||||
const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState<boolean[]>([]);
|
||||
const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState<boolean[]>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.targetTable);
|
||||
|
|
@ -129,6 +135,17 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
}
|
||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||||
|
||||
// conflictKeys 변경 시 Combobox 열림 상태 초기화
|
||||
useEffect(() => {
|
||||
setConflictKeysOpenState(new Array(conflictKeys.length).fill(false));
|
||||
}, [conflictKeys.length]);
|
||||
|
||||
// fieldMappings 변경 시 Combobox 열림 상태 초기화
|
||||
useEffect(() => {
|
||||
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
}, [fieldMappings.length]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||
useEffect(() => {
|
||||
const getAllSourceFields = (
|
||||
|
|
@ -326,12 +343,27 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
setColumnsLoading(true);
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
}));
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => {
|
||||
// is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환
|
||||
const isNullableValue = col.is_nullable ?? col.isNullable;
|
||||
let isNullable = true; // 기본값: nullable
|
||||
|
||||
if (typeof isNullableValue === "boolean") {
|
||||
isNullable = isNullableValue;
|
||||
} else if (typeof isNullableValue === "string") {
|
||||
isNullable = isNullableValue.toUpperCase() === "YES" || isNullableValue.toUpperCase() === "TRUE";
|
||||
} else if (typeof isNullableValue === "number") {
|
||||
isNullable = isNullableValue !== 0;
|
||||
}
|
||||
|
||||
return {
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable,
|
||||
columnDefault: col.column_default ?? col.columnDefault ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
setTargetColumns(columnInfo);
|
||||
} catch (error) {
|
||||
|
|
@ -401,12 +433,20 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
];
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
|
||||
// Combobox 열림 상태 배열 초기화
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
|
||||
// Combobox 열림 상태 배열도 업데이트
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -934,24 +974,46 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 충돌 키 추가 드롭다운 */}
|
||||
<Select onValueChange={handleAddConflictKey}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="충돌 키 추가..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns
|
||||
.filter((col) => !conflictKeys.includes(col.columnName))
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">{col.dataType}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 충돌 키 추가 Combobox */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
충돌 키 추가...
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="충돌 키 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{targetColumns
|
||||
.filter((col) => !conflictKeys.includes(col.columnName))
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={(currentValue) => {
|
||||
handleAddConflictKey(currentValue);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.dataType}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1010,32 +1072,84 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||
<Popover
|
||||
open={mappingSourceFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">
|
||||
연결된 소스 노드가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{mapping.sourceField
|
||||
? (() => {
|
||||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-medium">
|
||||
{field?.label || mapping.sourceField}
|
||||
</span>
|
||||
{field?.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: "소스 필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sourceFields.map((field) => (
|
||||
<CommandItem
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "sourceField", currentValue || null);
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -1043,30 +1157,91 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
<ArrowRight className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 드롭다운 */}
|
||||
{/* 타겟 필드 Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
||||
<Popover
|
||||
open={mappingTargetFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="타겟 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{col.dataType}
|
||||
{!col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingTargetFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{mapping.targetField
|
||||
? (() => {
|
||||
const col = targetColumns.find((c) => c.columnName === mapping.targetField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-mono">
|
||||
{col?.columnLabel || mapping.targetField}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{col?.dataType}
|
||||
{col && !col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: "타겟 필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="타겟 필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{targetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "targetField", currentValue);
|
||||
const newState = [...mappingTargetFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingTargetFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.targetField === col.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium">
|
||||
{col.columnLabel || col.columnName}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{col.dataType}
|
||||
{col.columnDefault ? (
|
||||
<span className="text-blue-600"> 🔵기본값</span>
|
||||
) : (
|
||||
!col.isNullable && <span className="text-red-500"> *필수</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
|
|
@ -1092,7 +1267,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { NodePaletteItem } from "@/types/node-editor";
|
|||
|
||||
export function NodePalette() {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(["source", "transform", "action", "utility"]),
|
||||
new Set(), // 기본적으로 모든 아코디언 닫힘
|
||||
);
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
|
|
|
|||
|
|
@ -3076,7 +3076,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
})
|
||||
: null;
|
||||
|
||||
// 일반 컴포넌트만 격자 스냅 적용 (그룹 제외)
|
||||
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
|
||||
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
|
||||
finalPosition = snapToGrid(
|
||||
{
|
||||
|
|
@ -3094,6 +3094,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
);
|
||||
|
||||
console.log("🎯 격자 스냅 적용됨:", {
|
||||
componentType: draggedComponent?.type,
|
||||
resolution: `${screenResolution.width}x${screenResolution.height}`,
|
||||
originalPosition: dragState.currentPosition,
|
||||
snappedPosition: finalPosition,
|
||||
|
|
@ -3516,12 +3517,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 고유한 그룹 ID 생성
|
||||
const newGroupId = generateGroupId();
|
||||
|
||||
// 버튼들을 그룹으로 묶기 (설정 포함)
|
||||
// 🔧 그룹 위치 및 버튼 재배치 계산
|
||||
const align = settings.align;
|
||||
const direction = settings.direction;
|
||||
const gap = settings.gap;
|
||||
|
||||
const groupY = Math.min(...selectedComponents.map((b) => b.position.y));
|
||||
let anchorButton; // 기준이 되는 버튼
|
||||
let groupX: number;
|
||||
|
||||
// align에 따라 기준 버튼과 그룹 시작점 결정
|
||||
if (direction === "horizontal") {
|
||||
if (align === "end") {
|
||||
// 끝점 정렬: 가장 오른쪽 버튼이 기준
|
||||
anchorButton = selectedComponents.reduce((max, btn) => {
|
||||
const rightEdge = btn.position.x + (btn.size?.width || 100);
|
||||
const maxRightEdge = max.position.x + (max.size?.width || 100);
|
||||
return rightEdge > maxRightEdge ? btn : max;
|
||||
});
|
||||
|
||||
// 전체 그룹 너비 계산
|
||||
const totalWidth = selectedComponents.reduce((total, btn, index) => {
|
||||
const buttonWidth = btn.size?.width || 100;
|
||||
const gapWidth = index < selectedComponents.length - 1 ? gap : 0;
|
||||
return total + buttonWidth + gapWidth;
|
||||
}, 0);
|
||||
|
||||
// 그룹 시작점 = 기준 버튼의 오른쪽 끝 - 전체 그룹 너비
|
||||
groupX = anchorButton.position.x + (anchorButton.size?.width || 100) - totalWidth;
|
||||
} else if (align === "center") {
|
||||
// 중앙 정렬: 버튼들의 중심점을 기준으로
|
||||
const minX = Math.min(...selectedComponents.map((b) => b.position.x));
|
||||
const maxX = Math.max(...selectedComponents.map((b) => b.position.x + (b.size?.width || 100)));
|
||||
const centerX = (minX + maxX) / 2;
|
||||
|
||||
const totalWidth = selectedComponents.reduce((total, btn, index) => {
|
||||
const buttonWidth = btn.size?.width || 100;
|
||||
const gapWidth = index < selectedComponents.length - 1 ? gap : 0;
|
||||
return total + buttonWidth + gapWidth;
|
||||
}, 0);
|
||||
|
||||
groupX = centerX - totalWidth / 2;
|
||||
anchorButton = selectedComponents[0]; // 중앙 정렬은 첫 번째 버튼 기준
|
||||
} else {
|
||||
// 시작점 정렬: 가장 왼쪽 버튼이 기준
|
||||
anchorButton = selectedComponents.reduce((min, btn) => {
|
||||
return btn.position.x < min.position.x ? btn : min;
|
||||
});
|
||||
groupX = anchorButton.position.x;
|
||||
}
|
||||
} else {
|
||||
// 세로 정렬: 가장 위쪽 버튼이 기준
|
||||
anchorButton = selectedComponents.reduce((min, btn) => {
|
||||
return btn.position.y < min.position.y ? btn : min;
|
||||
});
|
||||
groupX = Math.min(...selectedComponents.map((b) => b.position.x));
|
||||
}
|
||||
|
||||
// 🔧 버튼들의 위치를 그룹 기준으로 재배치
|
||||
// 기준 버튼의 절대 위치를 유지하고, FlexBox가 나머지를 자동 정렬
|
||||
const groupedButtons = selectedComponents.map((button) => {
|
||||
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {};
|
||||
|
||||
// 모든 버튼을 그룹 시작점에 배치
|
||||
// FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨
|
||||
const newPosition = {
|
||||
x: groupX,
|
||||
y: groupY,
|
||||
z: button.position.z || 1,
|
||||
};
|
||||
|
||||
return {
|
||||
...button,
|
||||
position: newPosition,
|
||||
webTypeConfig: {
|
||||
...(button as any).webTypeConfig,
|
||||
flowVisibilityConfig: {
|
||||
|
|
@ -3558,7 +3626,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
console.log("✅ 플로우 버튼 그룹 생성 완료:", {
|
||||
groupId: newGroupId,
|
||||
buttonCount: selectedComponents.length,
|
||||
buttons: selectedComponents.map((b) => b.id),
|
||||
buttons: selectedComponents.map((b) => ({ id: b.id, position: b.position })),
|
||||
groupPosition: { x: groupX, y: groupY },
|
||||
settings,
|
||||
});
|
||||
},
|
||||
|
|
@ -4316,86 +4385,109 @@ 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>
|
||||
{/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
|
||||
{(() => {
|
||||
// 선택된 컴포넌트들
|
||||
const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
|
||||
|
||||
// 버튼 컴포넌트만 필터링
|
||||
const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
|
||||
|
||||
// 플로우 그룹에 속한 버튼이 있는지 확인
|
||||
const hasFlowGroupButton = selectedButtons.some((btn) => {
|
||||
const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
|
||||
return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
|
||||
});
|
||||
|
||||
// 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
|
||||
const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return (
|
||||
<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">{selectedButtons.length}개 버튼 선택됨</span>
|
||||
</div>
|
||||
|
||||
{/* 그룹 생성 버튼 (2개 이상 선택 시) */}
|
||||
{selectedButtons.length >= 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleFlowButtonGroup}
|
||||
disabled={selectedButtons.length < 2}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
|
||||
{hasFlowGroupButton && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 상태 표시 */}
|
||||
{hasFlowGroupButton && <p className="mt-1 text-[10px] text-blue-600">✓ 플로우 그룹 버튼</p>}
|
||||
</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"
|
||||
|
|
@ -4707,20 +4799,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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 },
|
||||
);
|
||||
|
||||
// 🆕 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
|
||||
// 🔧 그룹의 위치 및 크기 계산
|
||||
// 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로
|
||||
// 첫 번째 버튼의 위치를 그룹 시작점으로 사용
|
||||
const direction = groupConfig.groupDirection || "horizontal";
|
||||
const gap = groupConfig.groupGap ?? 8;
|
||||
const align = groupConfig.groupAlign || "start";
|
||||
|
||||
const groupPosition = {
|
||||
x: buttons[0].position.x,
|
||||
y: buttons[0].position.y,
|
||||
z: buttons[0].position.z || 2,
|
||||
};
|
||||
|
||||
// 버튼들의 실제 크기 계산
|
||||
let groupWidth = 0;
|
||||
let groupHeight = 0;
|
||||
|
||||
|
|
@ -4731,12 +4823,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
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;
|
||||
|
|
@ -4744,6 +4834,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}, 0);
|
||||
}
|
||||
|
||||
// 🆕 그룹 전체가 선택되었는지 확인
|
||||
const isGroupSelected = buttons.every(
|
||||
(btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
|
||||
);
|
||||
const hasAnySelected = buttons.some(
|
||||
(btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`flow-button-group-${groupId}`}
|
||||
|
|
@ -4754,7 +4852,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
zIndex: groupPosition.z,
|
||||
width: `${groupWidth}px`, // 🆕 명시적 너비
|
||||
height: `${groupHeight}px`, // 🆕 명시적 높이
|
||||
pointerEvents: "none", // 그룹 컨테이너는 이벤트 차단하여 개별 버튼 클릭 가능
|
||||
}}
|
||||
className={hasAnySelected ? "rounded outline-2 outline-offset-2 outline-blue-500" : ""}
|
||||
>
|
||||
<FlowButtonGroup
|
||||
buttons={buttons}
|
||||
|
|
@ -4805,10 +4905,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
display: "inline-block",
|
||||
width: button.size?.width || 100,
|
||||
height: button.size?.height || 40,
|
||||
pointerEvents: "auto", // 개별 버튼은 이벤트 활성화
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
onMouseDown={(e) => {
|
||||
// 클릭이 아닌 드래그인 경우에만 드래그 시작
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleComponentClick(button, e);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let isDragging = false;
|
||||
let dragStarted = false;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = Math.abs(moveEvent.clientX - startX);
|
||||
const deltaY = Math.abs(moveEvent.clientY - startY);
|
||||
|
||||
// 5픽셀 이상 움직이면 드래그로 간주
|
||||
if ((deltaX > 5 || deltaY > 5) && !dragStarted) {
|
||||
isDragging = true;
|
||||
dragStarted = true;
|
||||
|
||||
// Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택
|
||||
if (!e.shiftKey) {
|
||||
const buttonIds = buttons.map((b) => b.id);
|
||||
setGroupState((prev) => ({
|
||||
...prev,
|
||||
selectedComponents: buttonIds,
|
||||
}));
|
||||
}
|
||||
|
||||
// 드래그 시작
|
||||
startComponentDrag(button, e as any);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
||||
// 드래그가 아니면 클릭으로 처리
|
||||
if (!isDragging) {
|
||||
// Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택
|
||||
if (!e.shiftKey) {
|
||||
const buttonIds = buttons.map((b) => b.id);
|
||||
setGroupState((prev) => ({
|
||||
...prev,
|
||||
selectedComponents: buttonIds,
|
||||
}));
|
||||
}
|
||||
handleComponentClick(button, e);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -4817,12 +4969,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
className={
|
||||
selectedComponent?.id === button.id ||
|
||||
groupState.selectedComponents.includes(button.id)
|
||||
? "outline outline-2 outline-offset-2 outline-blue-500"
|
||||
? "outline-1 outline-offset-1 outline-blue-400"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<div style={{ width: "100%", height: "100%", pointerEvents: "none" }}>
|
||||
<DynamicComponentRenderer
|
||||
component={relativeButton}
|
||||
isDesignMode={true}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,32 @@ export function FlowWidget({
|
|||
setStepCounts(countsMap);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 플로우 로드 후 첫 번째 스텝 자동 선택
|
||||
if (sortedSteps.length > 0) {
|
||||
const firstStep = sortedSteps[0];
|
||||
setSelectedStepId(firstStep.id);
|
||||
setSelectedStep(flowComponentId, firstStep.id);
|
||||
console.log("✅ [FlowWidget] 첫 번째 단계 자동 선택:", {
|
||||
flowComponentId,
|
||||
stepId: firstStep.id,
|
||||
stepName: firstStep.stepName,
|
||||
});
|
||||
|
||||
// 첫 번째 스텝의 데이터 로드
|
||||
try {
|
||||
const response = await getStepDataList(flowId!, firstStep.id, 1, 100);
|
||||
if (response.success) {
|
||||
const rows = response.data?.records || [];
|
||||
setStepData(rows);
|
||||
if (rows.length > 0) {
|
||||
setStepDataColumns(Object.keys(rows[0]));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("첫 번째 스텝 데이터 로드 실패:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to load flow data:", err);
|
||||
|
|
@ -732,12 +758,12 @@ export function FlowWidget({
|
|||
</div>
|
||||
|
||||
{/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */}
|
||||
<div className="hidden overflow-auto @sm:block" style={{ height: "450px" }}>
|
||||
<Table>
|
||||
<div className="relative hidden overflow-auto @sm:block" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableRow className="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">
|
||||
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
||||
onCheckedChange={toggleAllRows}
|
||||
|
|
@ -747,7 +773,7 @@ export function FlowWidget({
|
|||
{stepDataColumns.map((col) => (
|
||||
<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"
|
||||
className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
|
|
|
|||
Loading…
Reference in New Issue