chpark-sync #425
|
|
@ -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` 사용
|
||||||
|
- [ ] 모바일 반응형 대응 (카드 뷰)
|
||||||
|
- [ ] 다크 모드 호환 색상 사용
|
||||||
|
|
@ -105,7 +105,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-gray-50">
|
<div className="h-screen">
|
||||||
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */}
|
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */}
|
||||||
{/* <div className="border-b border-gray-200 bg-white px-6 py-4">
|
{/* <div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
|
|
||||||
// 연결 정보가 변경될 때 폼 데이터 업데이트
|
// 연결 정보가 변경될 때 폼 데이터 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 테스트 관련 상태 초기화
|
||||||
|
setTestResult(null);
|
||||||
|
|
||||||
if (connection) {
|
if (connection) {
|
||||||
setFormData({
|
setFormData({
|
||||||
...connection,
|
...connection,
|
||||||
|
|
@ -304,7 +307,9 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
|
@ -437,7 +442,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
|
@ -464,7 +469,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
>
|
>
|
||||||
{testingConnection ? "테스트 중..." : "연결 테스트"}
|
{testingConnection ? "테스트 중..." : "연결 테스트"}
|
||||||
</Button>
|
</Button>
|
||||||
{testingConnection && <div className="text-sm text-gray-500">연결을 확인하고 있습니다...</div>}
|
{testingConnection && <div className="text-muted-foreground text-sm">연결을 확인하고 있습니다...</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테스트 결과 표시 */}
|
{/* 테스트 결과 표시 */}
|
||||||
|
|
@ -492,7 +497,9 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
{!testResult.success && testResult.error && (
|
{!testResult.success && testResult.error && (
|
||||||
<div className="mt-2 text-xs">
|
<div className="mt-2 text-xs">
|
||||||
<div>오류 코드: {testResult.error.code}</div>
|
<div>오류 코드: {testResult.error.code}</div>
|
||||||
{testResult.error.details && <div className="mt-1 text-destructive">{testResult.error.details}</div>}
|
{testResult.error.details && (
|
||||||
|
<div className="text-destructive mt-1">{testResult.error.details}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -602,7 +609,11 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={loading} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
|
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ export function RestApiConnectionList() {
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="relative w-full sm:w-[300px]">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="연결명 또는 URL로 검색..."
|
placeholder="연결명 또는 URL로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -246,118 +246,125 @@ export function RestApiConnectionList() {
|
||||||
|
|
||||||
{/* 추가 버튼 */}
|
{/* 추가 버튼 */}
|
||||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />새 연결 추가
|
||||||
새 연결 추가
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 연결 목록 */}
|
{/* 연결 목록 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-64 items-center justify-center rounded-lg border bg-card shadow-sm">
|
<div className="bg-card flex h-64 items-center justify-center rounded-lg border shadow-sm">
|
||||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
) : connections.length === 0 ? (
|
) : connections.length === 0 ? (
|
||||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-sm text-muted-foreground">등록된 REST API 연결이 없습니다</p>
|
<p className="text-muted-foreground text-sm">등록된 REST API 연결이 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border bg-card shadow-sm">
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
<TableHead className="h-12 text-sm font-semibold">연결명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">연결명</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">기본 URL</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">기본 URL</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">인증 타입</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">인증 타입</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">헤더 수</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">헤더 수</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">상태</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">상태</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">마지막 테스트</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">마지막 테스트</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">연결 테스트</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">연결 테스트</TableHead>
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{connections.map((connection) => (
|
{connections.map((connection) => (
|
||||||
<TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50">
|
<TableRow key={connection.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
<TableCell className="h-16 text-sm">
|
<TableCell className="h-16 text-sm">
|
||||||
<div className="font-medium">{connection.connection_name}</div>
|
<div className="max-w-[200px]">
|
||||||
|
<div className="truncate font-medium" title={connection.connection_name}>
|
||||||
|
{connection.connection_name}
|
||||||
|
</div>
|
||||||
{connection.description && (
|
{connection.description && (
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{connection.description}</div>
|
<div className="text-muted-foreground mt-1 truncate text-xs" title={connection.description}>
|
||||||
)}
|
{connection.description}
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 font-mono text-sm">{connection.base_url}</TableCell>
|
|
||||||
<TableCell className="h-16 text-sm">
|
|
||||||
<Badge variant="outline">
|
|
||||||
{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-center text-sm">
|
|
||||||
{Object.keys(connection.default_headers || {}).length}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-sm">
|
|
||||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
|
|
||||||
{connection.is_active === "Y" ? "활성" : "비활성"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-sm">
|
|
||||||
{connection.last_test_date ? (
|
|
||||||
<div>
|
|
||||||
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
|
|
||||||
<Badge
|
|
||||||
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
|
|
||||||
className="mt-1"
|
|
||||||
>
|
|
||||||
{connection.last_test_result === "Y" ? "성공" : "실패"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">-</span>
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="h-16 text-sm">
|
</TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<TableCell className="h-16 font-mono text-sm">
|
||||||
<Button
|
<div className="max-w-[300px] truncate" title={connection.base_url}>
|
||||||
variant="outline"
|
{connection.base_url}
|
||||||
size="sm"
|
</div>
|
||||||
onClick={() => handleTestConnection(connection)}
|
</TableCell>
|
||||||
disabled={testingConnections.has(connection.id!)}
|
<TableCell className="h-16 text-sm">
|
||||||
className="h-9 text-sm"
|
<Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-center text-sm">
|
||||||
|
{Object.keys(connection.default_headers || {}).length}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-sm">
|
||||||
|
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
|
||||||
|
{connection.is_active === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-sm">
|
||||||
|
{connection.last_test_date ? (
|
||||||
|
<div>
|
||||||
|
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
|
||||||
|
<Badge
|
||||||
|
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
|
||||||
|
className="mt-1"
|
||||||
>
|
>
|
||||||
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
|
{connection.last_test_result === "Y" ? "성공" : "실패"}
|
||||||
</Button>
|
</Badge>
|
||||||
{testResults.has(connection.id!) && (
|
|
||||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
|
|
||||||
{testResults.get(connection.id!) ? "성공" : "실패"}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
) : (
|
||||||
<TableCell className="h-16 text-right">
|
<span className="text-muted-foreground">-</span>
|
||||||
<div className="flex justify-end gap-2">
|
)}
|
||||||
<Button
|
</TableCell>
|
||||||
variant="ghost"
|
<TableCell className="h-16 text-sm">
|
||||||
size="icon"
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => handleEditConnection(connection)}
|
<Button
|
||||||
className="h-8 w-8"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
<Pencil className="h-4 w-4" />
|
onClick={() => handleTestConnection(connection)}
|
||||||
</Button>
|
disabled={testingConnections.has(connection.id!)}
|
||||||
<Button
|
className="h-9 text-sm"
|
||||||
variant="ghost"
|
>
|
||||||
size="icon"
|
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
|
||||||
onClick={() => handleDeleteConnection(connection)}
|
</Button>
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
{testResults.has(connection.id!) && (
|
||||||
>
|
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
|
||||||
<Trash2 className="h-4 w-4" />
|
{testResults.get(connection.id!) ? "성공" : "실패"}
|
||||||
</Button>
|
</Badge>
|
||||||
</div>
|
)}
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
</TableCell>
|
||||||
))}
|
<TableCell className="h-16 text-right">
|
||||||
</TableBody>
|
<div className="flex justify-end gap-2">
|
||||||
</Table>
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleEditConnection(connection)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteConnection(connection)}
|
||||||
|
className="text-destructive hover:bg-destructive/10 h-8 w-8"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 연결 설정 모달 */}
|
{/* 연결 설정 모달 */}
|
||||||
|
|
@ -377,8 +384,7 @@ export function RestApiConnectionList() {
|
||||||
<AlertDialogTitle className="text-base sm:text-lg">연결 삭제 확인</AlertDialogTitle>
|
<AlertDialogTitle className="text-base sm:text-lg">연결 삭제 확인</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
||||||
<br />
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
이 작업은 되돌릴 수 없습니다.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
|
|
@ -390,7 +396,7 @@ export function RestApiConnectionList() {
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={confirmDeleteConnection}
|
onClick={confirmDeleteConnection}
|
||||||
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
const [testEndpoint, setTestEndpoint] = useState("");
|
const [testEndpoint, setTestEndpoint] = useState("");
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
|
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
|
||||||
|
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// 기존 연결 데이터 로드
|
// 기존 연결 데이터 로드
|
||||||
|
|
@ -77,6 +78,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
|
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
setTestEndpoint("");
|
setTestEndpoint("");
|
||||||
|
setTestRequestUrl("");
|
||||||
}, [connection, isOpen]);
|
}, [connection, isOpen]);
|
||||||
|
|
||||||
// 연결 테스트
|
// 연결 테스트
|
||||||
|
|
@ -94,6 +96,10 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
|
// 사용자가 테스트하려는 실제 외부 API URL 설정
|
||||||
|
const fullUrl = testEndpoint ? `${baseUrl}${testEndpoint}` : baseUrl;
|
||||||
|
setTestRequestUrl(fullUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await ExternalRestApiConnectionAPI.testConnection({
|
const result = await ExternalRestApiConnectionAPI.testConnection({
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
|
|
@ -220,7 +226,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="connection-name">
|
<Label htmlFor="connection-name">
|
||||||
연결명 <span className="text-red-500">*</span>
|
연결명 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="connection-name"
|
id="connection-name"
|
||||||
|
|
@ -243,7 +249,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="base-url">
|
<Label htmlFor="base-url">
|
||||||
기본 URL <span className="text-red-500">*</span>
|
기본 URL <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="base-url"
|
id="base-url"
|
||||||
|
|
@ -283,14 +289,14 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
className="flex items-center space-x-2 text-sm font-semibold hover:text-blue-600"
|
className="hover:text-primary flex items-center space-x-2 text-sm font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
<span>고급 설정</span>
|
<span>고급 설정</span>
|
||||||
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
|
<div className="bg-muted space-y-4 rounded-md border p-4">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="timeout">타임아웃 (ms)</Label>
|
<Label htmlFor="timeout">타임아웃 (ms)</Label>
|
||||||
|
|
@ -342,7 +348,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
id="test-endpoint"
|
id="test-endpoint"
|
||||||
value={testEndpoint}
|
value={testEndpoint}
|
||||||
onChange={(e) => setTestEndpoint(e.target.value)}
|
onChange={(e) => setTestEndpoint(e.target.value)}
|
||||||
placeholder="/api/v1/test 또는 빈칸 (기본 URL만 테스트)"
|
placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -351,6 +357,41 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
{testing ? "테스트 중..." : "연결 테스트"}
|
{testing ? "테스트 중..." : "연결 테스트"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* 테스트 요청 정보 표시 */}
|
||||||
|
{testRequestUrl && (
|
||||||
|
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청 URL</div>
|
||||||
|
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(defaultHeaders).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-1 text-xs font-medium">요청 헤더</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Object.entries(defaultHeaders).map(([key, value]) => (
|
||||||
|
<code key={key} className="text-foreground block text-xs">
|
||||||
|
{key}: {value}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authType !== "none" && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-1 text-xs font-medium">인증 방식</div>
|
||||||
|
<code className="text-foreground block text-xs">
|
||||||
|
{authType === "api-key" && "API Key"}
|
||||||
|
{authType === "bearer" && "Bearer Token"}
|
||||||
|
{authType === "basic" && "Basic Auth"}
|
||||||
|
{authType === "oauth2" && "OAuth 2.0"}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div
|
<div
|
||||||
className={`rounded-md border p-4 ${
|
className={`rounded-md border p-4 ${
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { DashboardElement, QueryResult, ChartData } from "../types";
|
import { DashboardElement, QueryResult, ChartData } from "../types";
|
||||||
import { Chart } from "./Chart";
|
import { Chart } from "./Chart";
|
||||||
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
||||||
|
|
@ -21,11 +21,39 @@ interface ChartRendererProps {
|
||||||
* - QueryResult를 ChartData로 변환
|
* - QueryResult를 ChartData로 변환
|
||||||
* - D3 Chart 컴포넌트에 전달
|
* - D3 Chart 컴포넌트에 전달
|
||||||
*/
|
*/
|
||||||
export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) {
|
export function ChartRenderer({ element, data, width, height = 200 }: ChartRendererProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(width || 250);
|
||||||
const [chartData, setChartData] = useState<ChartData | null>(null);
|
const [chartData, setChartData] = useState<ChartData | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 컨테이너 너비 측정 (width가 undefined일 때)
|
||||||
|
useEffect(() => {
|
||||||
|
if (width !== undefined) {
|
||||||
|
setContainerWidth(width);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const measuredWidth = containerRef.current.offsetWidth;
|
||||||
|
console.log("📏 컨테이너 너비 측정:", measuredWidth);
|
||||||
|
setContainerWidth(measuredWidth || 500); // 기본값 500
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 약간의 지연을 두고 측정 (DOM 렌더링 완료 후)
|
||||||
|
const timer = setTimeout(updateWidth, 100);
|
||||||
|
updateWidth();
|
||||||
|
|
||||||
|
window.addEventListener("resize", updateWidth);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
window.removeEventListener("resize", updateWidth);
|
||||||
|
};
|
||||||
|
}, [width]);
|
||||||
|
|
||||||
// 데이터 페칭
|
// 데이터 페칭
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
|
@ -212,15 +240,39 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||||
}
|
}
|
||||||
|
|
||||||
// D3 차트 렌더링
|
// D3 차트 렌더링
|
||||||
|
const actualWidth = width !== undefined ? width : containerWidth;
|
||||||
|
|
||||||
|
// 원형 차트는 더 큰 크기가 필요 (최소 400px)
|
||||||
|
const isCircularChart = element.subtype === "pie" || element.subtype === "donut";
|
||||||
|
const minWidth = isCircularChart ? 400 : 200;
|
||||||
|
const finalWidth = Math.max(actualWidth - 20, minWidth);
|
||||||
|
// 원형 차트는 범례 공간을 위해 더 많은 여백 필요
|
||||||
|
const finalHeight = Math.max(height - (isCircularChart ? 60 : 20), 300);
|
||||||
|
|
||||||
|
console.log("🎨 ChartRenderer:", {
|
||||||
|
elementSubtype: element.subtype,
|
||||||
|
propWidth: width,
|
||||||
|
containerWidth,
|
||||||
|
actualWidth,
|
||||||
|
finalWidth,
|
||||||
|
finalHeight,
|
||||||
|
hasChartData: !!chartData,
|
||||||
|
chartDataLabels: chartData?.labels,
|
||||||
|
chartDataDatasets: chartData?.datasets?.length,
|
||||||
|
isCircularChart,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-white p-2">
|
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-white p-2">
|
||||||
<Chart
|
<div className="flex items-center justify-center">
|
||||||
chartType={element.subtype}
|
<Chart
|
||||||
data={chartData}
|
chartType={element.subtype}
|
||||||
config={element.chartConfig}
|
data={chartData}
|
||||||
width={width - 20}
|
config={element.chartConfig}
|
||||||
height={height - 20}
|
width={finalWidth}
|
||||||
/>
|
height={finalHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,17 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa
|
||||||
const svg = d3.select(svgRef.current);
|
const svg = d3.select(svgRef.current);
|
||||||
svg.selectAll("*").remove();
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
const margin = { top: 40, right: 150, bottom: 40, left: 120 };
|
// 범례를 위한 여백 확보 (아래 80px)
|
||||||
|
const legendHeight = config.showLegend !== false ? 80 : 0;
|
||||||
|
const margin = { top: 20, right: 20, bottom: 20 + legendHeight, left: 20 };
|
||||||
const chartWidth = width - margin.left - margin.right;
|
const chartWidth = width - margin.left - margin.right;
|
||||||
const chartHeight = height - margin.top - margin.bottom;
|
const chartHeight = height - margin.top - margin.bottom - legendHeight;
|
||||||
const radius = Math.min(chartWidth, chartHeight) / 2;
|
const radius = Math.min(chartWidth, chartHeight) / 2;
|
||||||
|
|
||||||
const g = svg.append("g").attr("transform", `translate(${width / 2},${height / 2})`);
|
// 차트를 위쪽에 배치 (범례 공간 확보)
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = margin.top + radius + 20;
|
||||||
|
const g = svg.append("g").attr("transform", `translate(${centerX},${centerY})`);
|
||||||
|
|
||||||
// 색상 팔레트
|
// 색상 팔레트
|
||||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"];
|
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"];
|
||||||
|
|
@ -136,33 +141,35 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa
|
||||||
.text(config.title);
|
.text(config.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 범례 (차트 오른쪽, 세로 배치)
|
// 범례 (차트 아래, 가로 배치, 중앙 정렬)
|
||||||
if (config.showLegend !== false) {
|
if (config.showLegend !== false) {
|
||||||
const legendX = width / 2 + radius + 30; // 차트 오른쪽
|
const itemSpacing = 100; // 각 범례 항목 사이 간격 (줄임)
|
||||||
const legendY = (height - pieData.length * 25) / 2; // 세로 중앙 정렬
|
const totalWidth = pieData.length * itemSpacing;
|
||||||
|
const legendStartX = (width - totalWidth) / 2; // 시작 위치
|
||||||
|
const legendY = centerY + radius + 40; // 차트 아래 40px
|
||||||
|
|
||||||
const legend = svg
|
const legend = svg.append("g").attr("class", "legend");
|
||||||
.append("g")
|
|
||||||
.attr("class", "legend")
|
|
||||||
.attr("transform", `translate(${legendX}, ${legendY})`);
|
|
||||||
|
|
||||||
pieData.forEach((d, i) => {
|
pieData.forEach((d, i) => {
|
||||||
const legendItem = legend
|
const legendItem = legend
|
||||||
.append("g")
|
.append("g")
|
||||||
.attr("transform", `translate(0, ${i * 25})`);
|
.attr("transform", `translate(${legendStartX + i * itemSpacing + itemSpacing / 2}, ${legendY})`);
|
||||||
|
|
||||||
legendItem
|
legendItem
|
||||||
.append("rect")
|
.append("rect")
|
||||||
.attr("width", 15)
|
.attr("x", -6) // 사각형을 중앙 기준으로
|
||||||
.attr("height", 15)
|
.attr("y", -6)
|
||||||
|
.attr("width", 12)
|
||||||
|
.attr("height", 12)
|
||||||
.attr("fill", colors[i % colors.length])
|
.attr("fill", colors[i % colors.length])
|
||||||
.attr("rx", 3);
|
.attr("rx", 2);
|
||||||
|
|
||||||
legendItem
|
legendItem
|
||||||
.append("text")
|
.append("text")
|
||||||
.attr("x", 20)
|
.attr("x", 0)
|
||||||
.attr("y", 12)
|
.attr("y", 18)
|
||||||
.style("font-size", "11px")
|
.attr("text-anchor", "middle") // 텍스트 중앙 정렬
|
||||||
|
.style("font-size", "10px")
|
||||||
.style("fill", "#333")
|
.style("fill", "#333")
|
||||||
.text(`${d.label} (${d.value})`);
|
.text(`${d.label} (${d.value})`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -111,19 +111,21 @@ export function ClockWidget({ element, onConfigUpdate }: ClockWidgetProps) {
|
||||||
{/* 시계 콘텐츠 */}
|
{/* 시계 콘텐츠 */}
|
||||||
{renderClockContent()}
|
{renderClockContent()}
|
||||||
|
|
||||||
{/* 설정 버튼 - 우측 상단 */}
|
{/* 설정 버튼 - 우측 상단 (디자이너 모드에서만 표시) */}
|
||||||
<div className="absolute top-2 right-2">
|
{onConfigUpdate && (
|
||||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
<div className="absolute top-2 right-2">
|
||||||
<PopoverTrigger asChild>
|
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
|
<PopoverTrigger asChild>
|
||||||
<Settings className="h-4 w-4" />
|
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
|
||||||
</Button>
|
<Settings className="h-4 w-4" />
|
||||||
</PopoverTrigger>
|
</Button>
|
||||||
<PopoverContent className="w-[500px] p-0" align="end">
|
</PopoverTrigger>
|
||||||
<ClockSettings config={config} onSave={handleSaveSettings} onClose={() => setSettingsOpen(false)} />
|
<PopoverContent className="w-[500px] p-0" align="end">
|
||||||
</PopoverContent>
|
<ClockSettings config={config} onSave={handleSaveSettings} onClose={() => setSettingsOpen(false)} />
|
||||||
</Popover>
|
</PopoverContent>
|
||||||
</div>
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -216,12 +216,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
|
const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col p-4">
|
<div className="flex h-full w-full flex-col gap-3 p-4">
|
||||||
{/* 제목 - 항상 표시 */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700">{element.customTitle || element.title}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 뷰 */}
|
{/* 테이블 뷰 */}
|
||||||
{config.viewMode === "table" && (
|
{config.viewMode === "table" && (
|
||||||
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
||||||
|
|
@ -311,7 +306,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{config.enablePagination && totalPages > 1 && (
|
{config.enablePagination && totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between text-sm">
|
<div className="flex shrink-0 items-center justify-between border-t pt-3 text-sm">
|
||||||
<div className="text-gray-600">
|
<div className="text-gray-600">
|
||||||
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
|
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Canvas, useThree } from "@react-three/fiber";
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
import { OrbitControls, Grid, Box } from "@react-three/drei";
|
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
|
||||||
import { Suspense, useRef, useState, useEffect } from "react";
|
import { Suspense, useRef, useState, useEffect } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
|
@ -29,6 +29,19 @@ interface Yard3DCanvasProps {
|
||||||
selectedPlacementId: number | null;
|
selectedPlacementId: number | null;
|
||||||
onPlacementClick: (placement: YardPlacement | null) => void;
|
onPlacementClick: (placement: YardPlacement | null) => void;
|
||||||
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
|
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
|
||||||
|
gridSize?: number; // 그리드 크기 (기본값: 5)
|
||||||
|
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
|
||||||
|
}
|
||||||
|
|
||||||
|
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
||||||
|
// Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음
|
||||||
|
function snapToGrid(value: number, gridSize: number): number {
|
||||||
|
// 가장 가까운 그리드 칸 찾기
|
||||||
|
const gridIndex = Math.round(value / gridSize);
|
||||||
|
// 그리드 칸의 중심점 반환
|
||||||
|
// gridSize=5일 때: ..., -7.5, -2.5, 2.5, 7.5, 12.5, 17.5...
|
||||||
|
// 이렇게 하면 Box가 칸 안에 정확히 들어감
|
||||||
|
return gridIndex * gridSize + gridSize / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자재 박스 컴포넌트 (드래그 가능)
|
// 자재 박스 컴포넌트 (드래그 가능)
|
||||||
|
|
@ -39,6 +52,9 @@ function MaterialBox({
|
||||||
onDrag,
|
onDrag,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
gridSize = 5,
|
||||||
|
allPlacements = [],
|
||||||
|
onCollisionDetected,
|
||||||
}: {
|
}: {
|
||||||
placement: YardPlacement;
|
placement: YardPlacement;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
|
@ -46,6 +62,9 @@ function MaterialBox({
|
||||||
onDrag?: (position: { x: number; y: number; z: number }) => void;
|
onDrag?: (position: { x: number; y: number; z: number }) => void;
|
||||||
onDragStart?: () => void;
|
onDragStart?: () => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
|
gridSize?: number;
|
||||||
|
allPlacements?: YardPlacement[];
|
||||||
|
onCollisionDetected?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
@ -53,10 +72,83 @@ function MaterialBox({
|
||||||
const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
const { camera, gl } = useThree();
|
const { camera, gl } = useThree();
|
||||||
|
|
||||||
// 드래그 중이 아닐 때 위치 업데이트
|
// 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정
|
||||||
|
const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => {
|
||||||
|
const palletHeight = 0.3; // 팔레트 높이
|
||||||
|
const palletGap = 0.05; // 팔레트와 박스 사이 간격
|
||||||
|
|
||||||
|
const mySize = placement.size_x || gridSize; // 내 크기 (5)
|
||||||
|
const myHalfSize = mySize / 2; // 2.5
|
||||||
|
const mySizeY = placement.size_y || gridSize; // 박스 높이 (5)
|
||||||
|
const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이
|
||||||
|
|
||||||
|
let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5)
|
||||||
|
|
||||||
|
for (const p of allPlacements) {
|
||||||
|
// 자기 자신은 제외
|
||||||
|
if (Number(p.id) === Number(placement.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pSize = p.size_x || gridSize; // 상대방 크기 (5)
|
||||||
|
const pHalfSize = pSize / 2; // 2.5
|
||||||
|
const pSizeY = p.size_y || gridSize; // 상대방 박스 높이 (5)
|
||||||
|
const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이
|
||||||
|
|
||||||
|
// 1단계: 넓은 범위로 겹침 감지 (살짝만 가까이 가도 감지)
|
||||||
|
const detectionMargin = 0.5; // 감지 범위 확장 (0.5 유닛)
|
||||||
|
const isNearby =
|
||||||
|
Math.abs(x - p.position_x) < myHalfSize + pHalfSize + detectionMargin && // X축 근접
|
||||||
|
Math.abs(z - p.position_z) < myHalfSize + pHalfSize + detectionMargin; // Z축 근접
|
||||||
|
|
||||||
|
if (isNearby) {
|
||||||
|
// 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정)
|
||||||
|
const isActuallyOverlapping =
|
||||||
|
Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침
|
||||||
|
Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침
|
||||||
|
|
||||||
|
if (isActuallyOverlapping) {
|
||||||
|
// 실제로 겹침: 위에 배치
|
||||||
|
// 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산
|
||||||
|
const topOfOtherElement = p.position_y + pTotalHeight / 2;
|
||||||
|
// 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산
|
||||||
|
const myYOnTop = topOfOtherElement + myTotalHeight / 2;
|
||||||
|
|
||||||
|
if (myYOnTop > maxYBelow) {
|
||||||
|
maxYBelow = myYOnTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함)
|
||||||
|
const needsAdjustment = Math.abs(y - maxYBelow) > 0.1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasCollision: needsAdjustment,
|
||||||
|
adjustedY: maxYBelow,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 중이 아닐 때만 위치 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDragging && meshRef.current) {
|
if (!isDragging && meshRef.current) {
|
||||||
meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z);
|
const currentPos = meshRef.current.position;
|
||||||
|
const targetX = placement.position_x;
|
||||||
|
const targetY = placement.position_y;
|
||||||
|
const targetZ = placement.position_z;
|
||||||
|
|
||||||
|
// 현재 위치와 목표 위치가 다를 때만 업데이트 (0.01 이상 차이)
|
||||||
|
const threshold = 0.01;
|
||||||
|
const needsUpdate =
|
||||||
|
Math.abs(currentPos.x - targetX) > threshold ||
|
||||||
|
Math.abs(currentPos.y - targetY) > threshold ||
|
||||||
|
Math.abs(currentPos.z - targetZ) > threshold;
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
meshRef.current.position.set(targetX, targetY, targetZ);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [placement.position_x, placement.position_y, placement.position_z, isDragging]);
|
}, [placement.position_x, placement.position_y, placement.position_z, isDragging]);
|
||||||
|
|
||||||
|
|
@ -98,20 +190,56 @@ function MaterialBox({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 즉시 mesh 위치 업데이트 (부드러운 드래그)
|
// 그리드에 스냅
|
||||||
meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ);
|
const snappedX = snapToGrid(finalX, gridSize);
|
||||||
|
const snappedZ = snapToGrid(finalZ, gridSize);
|
||||||
|
|
||||||
// 상태 업데이트 (저장용)
|
// 충돌 체크 및 Y 위치 조정
|
||||||
onDrag({
|
const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ);
|
||||||
x: finalX,
|
|
||||||
y: dragStartPos.current.y,
|
// 즉시 mesh 위치 업데이트 (조정된 Y 위치로)
|
||||||
z: finalZ,
|
meshRef.current.position.set(finalX, adjustedY, finalZ);
|
||||||
});
|
|
||||||
|
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
|
||||||
|
// 실제 저장은 handleGlobalMouseUp에서만 수행
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGlobalMouseUp = () => {
|
const handleGlobalMouseUp = () => {
|
||||||
if (isDragging) {
|
if (isDragging && meshRef.current) {
|
||||||
|
const currentPos = meshRef.current.position;
|
||||||
|
|
||||||
|
// 실제로 이동했는지 확인 (최소 이동 거리: 0.1)
|
||||||
|
const minMovement = 0.1;
|
||||||
|
const deltaX = Math.abs(currentPos.x - dragStartPos.current.x);
|
||||||
|
const deltaZ = Math.abs(currentPos.z - dragStartPos.current.z);
|
||||||
|
const hasMoved = deltaX > minMovement || deltaZ > minMovement;
|
||||||
|
|
||||||
|
if (hasMoved) {
|
||||||
|
// 실제로 드래그한 경우: 그리드에 스냅
|
||||||
|
const snappedX = snapToGrid(currentPos.x, gridSize);
|
||||||
|
const snappedZ = snapToGrid(currentPos.z, gridSize);
|
||||||
|
|
||||||
|
// Y 위치 조정 (마인크래프트처럼 쌓기)
|
||||||
|
const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ);
|
||||||
|
|
||||||
|
// ✅ 항상 배치 가능 (위로 올라가므로)
|
||||||
|
console.log("✅ 배치 완료! 저장:", { x: snappedX, y: adjustedY, z: snappedZ });
|
||||||
|
meshRef.current.position.set(snappedX, adjustedY, snappedZ);
|
||||||
|
|
||||||
|
// 최종 위치 저장 (조정된 Y 위치로)
|
||||||
|
if (onDrag) {
|
||||||
|
onDrag({
|
||||||
|
x: snappedX,
|
||||||
|
y: adjustedY,
|
||||||
|
z: snappedZ,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 클릭만 한 경우: 원래 위치 유지 (아무것도 안 함)
|
||||||
|
meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z);
|
||||||
|
}
|
||||||
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
|
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
|
||||||
if (onDragEnd) {
|
if (onDragEnd) {
|
||||||
|
|
@ -141,11 +269,12 @@ function MaterialBox({
|
||||||
|
|
||||||
// 편집 모드에서 선택되었고 드래그 가능한 경우
|
// 편집 모드에서 선택되었고 드래그 가능한 경우
|
||||||
if (isSelected && meshRef.current) {
|
if (isSelected && meshRef.current) {
|
||||||
// 드래그 시작 시점의 자재 위치 저장 (숫자로 변환)
|
// 드래그 시작 시점의 mesh 실제 위치 저장 (현재 렌더링된 위치)
|
||||||
|
const currentPos = meshRef.current.position;
|
||||||
dragStartPos.current = {
|
dragStartPos.current = {
|
||||||
x: Number(placement.position_x),
|
x: currentPos.x,
|
||||||
y: Number(placement.position_y),
|
y: currentPos.y,
|
||||||
z: Number(placement.position_z),
|
z: currentPos.z,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 마우스 시작 위치 저장
|
// 마우스 시작 위치 저장
|
||||||
|
|
@ -165,11 +294,19 @@ function MaterialBox({
|
||||||
// 요소가 설정되었는지 확인
|
// 요소가 설정되었는지 확인
|
||||||
const isConfigured = !!(placement.material_name && placement.quantity && placement.unit);
|
const isConfigured = !!(placement.material_name && placement.quantity && placement.unit);
|
||||||
|
|
||||||
|
const boxHeight = placement.size_y || gridSize;
|
||||||
|
const boxWidth = placement.size_x || gridSize;
|
||||||
|
const boxDepth = placement.size_z || gridSize;
|
||||||
|
const palletHeight = 0.3; // 팔레트 높이
|
||||||
|
const palletGap = 0.05; // 팔레트와 박스 사이 간격 (매우 작게)
|
||||||
|
|
||||||
|
// 팔레트 위치 계산: 박스 하단부터 시작
|
||||||
|
const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<group
|
||||||
ref={meshRef}
|
ref={meshRef}
|
||||||
position={[placement.position_x, placement.position_y, placement.position_z]}
|
position={[placement.position_x, placement.position_y, placement.position_z]}
|
||||||
args={[placement.size_x, placement.size_y, placement.size_z]}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.nativeEvent?.stopPropagation();
|
e.nativeEvent?.stopPropagation();
|
||||||
|
|
@ -178,7 +315,6 @@ function MaterialBox({
|
||||||
}}
|
}}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerOver={() => {
|
onPointerOver={() => {
|
||||||
// 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서
|
|
||||||
if (onDrag) {
|
if (onDrag) {
|
||||||
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
|
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -191,20 +327,154 @@ function MaterialBox({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<meshStandardMaterial
|
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
|
||||||
color={placement.color}
|
<group position={[0, palletYOffset, 0]}>
|
||||||
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
{/* 상단 가로 판자들 (5개) */}
|
||||||
transparent
|
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
|
||||||
emissive={isSelected ? "#ffffff" : "#000000"}
|
<Box
|
||||||
emissiveIntensity={isSelected ? 0.2 : 0}
|
key={`top-${idx}`}
|
||||||
wireframe={!isConfigured}
|
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
|
||||||
/>
|
position={[0, palletHeight * 0.35, zOffset]}
|
||||||
</Box>
|
>
|
||||||
|
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
|
||||||
|
<lineSegments>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]} />
|
||||||
|
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
||||||
|
</lineSegments>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 중간 세로 받침대 (3개) */}
|
||||||
|
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
|
||||||
|
<Box
|
||||||
|
key={`middle-${idx}`}
|
||||||
|
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
|
||||||
|
position={[xOffset, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
|
||||||
|
<lineSegments>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]} />
|
||||||
|
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
|
||||||
|
</lineSegments>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 하단 가로 판자들 (3개) */}
|
||||||
|
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
|
||||||
|
<Box
|
||||||
|
key={`bottom-${idx}`}
|
||||||
|
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
|
||||||
|
position={[0, -palletHeight * 0.35, zOffset]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
|
||||||
|
<lineSegments>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]} />
|
||||||
|
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
||||||
|
</lineSegments>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* 메인 박스 */}
|
||||||
|
<Box args={[boxWidth, boxHeight, boxDepth]} position={[0, 0, 0]}>
|
||||||
|
{/* 메인 재질 - 골판지 느낌 */}
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={placement.color}
|
||||||
|
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
||||||
|
transparent
|
||||||
|
emissive={isSelected ? "#ffffff" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? 0.2 : 0}
|
||||||
|
wireframe={!isConfigured}
|
||||||
|
roughness={0.95}
|
||||||
|
metalness={0.05}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 외곽선 - 더 진하게 */}
|
||||||
|
<lineSegments>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth)]} />
|
||||||
|
<lineBasicMaterial color="#1a1a1a" opacity={0.8} transparent />
|
||||||
|
</lineSegments>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 포장 테이프 (가로) - 윗면 */}
|
||||||
|
{isConfigured && (
|
||||||
|
<>
|
||||||
|
{/* 테이프 세로 */}
|
||||||
|
<Box args={[boxWidth * 0.12, 0.02, boxDepth * 0.95]} position={[0, boxHeight / 2 + 0.01, 0]}>
|
||||||
|
<meshStandardMaterial color="#d4a574" opacity={0.7} transparent roughness={0.3} metalness={0.3} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */}
|
||||||
|
{isConfigured && placement.material_name && (
|
||||||
|
<group position={[0, boxHeight * 0.1, boxDepth / 2 + 0.02]}>
|
||||||
|
{/* 라벨 배경 (흰색 스티커) */}
|
||||||
|
<Box args={[boxWidth * 0.7, boxHeight * 0.25, 0.01]}>
|
||||||
|
<meshStandardMaterial color="#ffffff" roughness={0.4} metalness={0.1} />
|
||||||
|
<lineSegments>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.7, boxHeight * 0.25, 0.01)]} />
|
||||||
|
<lineBasicMaterial color="#cccccc" opacity={0.8} transparent />
|
||||||
|
</lineSegments>
|
||||||
|
</Box>
|
||||||
|
{/* 라벨 텍스트 */}
|
||||||
|
<Text
|
||||||
|
position={[0, 0, 0.02]}
|
||||||
|
fontSize={0.3}
|
||||||
|
color="#000000"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{placement.material_name}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 수량 라벨 (윗면) - 큰 글씨 */}
|
||||||
|
{isConfigured && placement.quantity && (
|
||||||
|
<Text
|
||||||
|
position={[0, boxHeight / 2 + 0.03, 0]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={0.6}
|
||||||
|
color="#000000"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
outlineWidth={0.1}
|
||||||
|
outlineColor="#ffffff"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{placement.quantity} {placement.unit || ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 디테일 표시 */}
|
||||||
|
{isConfigured && (
|
||||||
|
<>
|
||||||
|
{/* 화살표 표시 (이 쪽이 위) */}
|
||||||
|
<group position={[0, boxHeight * 0.35, boxDepth / 2 + 0.01]}>
|
||||||
|
<Text fontSize={0.6} color="#000000" anchorX="center" anchorY="middle">
|
||||||
|
▲
|
||||||
|
</Text>
|
||||||
|
<Text position={[0, -0.4, 0]} fontSize={0.3} color="#666666" anchorX="center" anchorY="middle">
|
||||||
|
UP
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3D 씬 컴포넌트
|
// 3D 씬 컴포넌트
|
||||||
function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
|
function Scene({
|
||||||
|
placements,
|
||||||
|
selectedPlacementId,
|
||||||
|
onPlacementClick,
|
||||||
|
onPlacementDrag,
|
||||||
|
gridSize = 5,
|
||||||
|
onCollisionDetected,
|
||||||
|
}: Yard3DCanvasProps) {
|
||||||
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
||||||
const orbitControlsRef = useRef<any>(null);
|
const orbitControlsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
|
@ -215,15 +485,15 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
|
||||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||||
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
||||||
|
|
||||||
{/* 바닥 그리드 */}
|
{/* 바닥 그리드 (타일을 4등분) */}
|
||||||
<Grid
|
<Grid
|
||||||
args={[100, 100]}
|
args={[100, 100]}
|
||||||
cellSize={5}
|
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
||||||
cellThickness={0.5}
|
cellThickness={0.6}
|
||||||
cellColor="#6b7280"
|
cellColor="#1f2937" // 얇은 선 (서브 그리드) - 매우 어두운 회색
|
||||||
sectionSize={10}
|
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
||||||
sectionThickness={1}
|
sectionThickness={1.5}
|
||||||
sectionColor="#374151"
|
sectionColor="#374151" // 타일 경계는 조금 밝게
|
||||||
fadeDistance={200}
|
fadeDistance={200}
|
||||||
fadeStrength={1}
|
fadeStrength={1}
|
||||||
followCamera={false}
|
followCamera={false}
|
||||||
|
|
@ -250,6 +520,9 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
|
||||||
orbitControlsRef.current.enabled = true;
|
orbitControlsRef.current.enabled = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
gridSize={gridSize}
|
||||||
|
allPlacements={placements}
|
||||||
|
onCollisionDetected={onCollisionDetected}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -259,10 +532,13 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
|
||||||
enablePan={true}
|
enablePan={true}
|
||||||
enableZoom={true}
|
enableZoom={true}
|
||||||
enableRotate={true}
|
enableRotate={true}
|
||||||
minDistance={10}
|
minDistance={8}
|
||||||
maxDistance={200}
|
maxDistance={200}
|
||||||
maxPolarAngle={Math.PI / 2}
|
maxPolarAngle={Math.PI / 2}
|
||||||
enabled={!isDraggingAny}
|
enabled={!isDraggingAny}
|
||||||
|
screenSpacePanning={true} // 화면 공간 패닝
|
||||||
|
panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림)
|
||||||
|
rotateSpeed={0.5} // 회전 속도
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -273,6 +549,8 @@ export default function Yard3DCanvas({
|
||||||
selectedPlacementId,
|
selectedPlacementId,
|
||||||
onPlacementClick,
|
onPlacementClick,
|
||||||
onPlacementDrag,
|
onPlacementDrag,
|
||||||
|
gridSize = 5,
|
||||||
|
onCollisionDetected,
|
||||||
}: Yard3DCanvasProps) {
|
}: Yard3DCanvasProps) {
|
||||||
const handleCanvasClick = (e: any) => {
|
const handleCanvasClick = (e: any) => {
|
||||||
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
||||||
|
|
@ -297,6 +575,8 @@ export default function Yard3DCanvas({
|
||||||
selectedPlacementId={selectedPlacementId}
|
selectedPlacementId={selectedPlacementId}
|
||||||
onPlacementClick={onPlacementClick}
|
onPlacementClick={onPlacementClick}
|
||||||
onPlacementDrag={onPlacementDrag}
|
onPlacementDrag={onPlacementDrag}
|
||||||
|
gridSize={gridSize}
|
||||||
|
onCollisionDetected={onCollisionDetected}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@ import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { YardLayout, YardPlacement } from "./types";
|
import { YardLayout, YardPlacement } from "./types";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { AlertCircle, CheckCircle } from "lucide-react";
|
import { AlertCircle, CheckCircle, XCircle } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -33,6 +34,7 @@ interface YardEditorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
const [placements, setPlacements] = useState<YardPlacement[]>([]);
|
const [placements, setPlacements] = useState<YardPlacement[]>([]);
|
||||||
const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관
|
const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관
|
||||||
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
|
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
|
||||||
|
|
@ -63,7 +65,17 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id);
|
const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
const loadedData = response.data as YardPlacement[];
|
const loadedData = (response.data as YardPlacement[]).map((p) => ({
|
||||||
|
...p,
|
||||||
|
// 문자열로 저장된 숫자 필드를 숫자로 변환
|
||||||
|
position_x: Number(p.position_x),
|
||||||
|
position_y: Number(p.position_y),
|
||||||
|
position_z: Number(p.position_z),
|
||||||
|
size_x: Number(p.size_x),
|
||||||
|
size_y: Number(p.size_y),
|
||||||
|
size_z: Number(p.size_z),
|
||||||
|
quantity: p.quantity !== null && p.quantity !== undefined ? Number(p.quantity) : null,
|
||||||
|
}));
|
||||||
setPlacements(loadedData);
|
setPlacements(loadedData);
|
||||||
setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사
|
setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사
|
||||||
}
|
}
|
||||||
|
|
@ -78,8 +90,89 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
loadPlacements();
|
loadPlacements();
|
||||||
}, [layout.id]);
|
}, [layout.id]);
|
||||||
|
|
||||||
|
// 빈 공간 찾기 (그리드 기반)
|
||||||
|
const findEmptyGridPosition = (gridSize = 5) => {
|
||||||
|
// 이미 사용 중인 좌표 Set
|
||||||
|
const occupiedPositions = new Set(
|
||||||
|
placements.map((p) => {
|
||||||
|
const x = Math.round(p.position_x / gridSize) * gridSize;
|
||||||
|
const z = Math.round(p.position_z / gridSize) * gridSize;
|
||||||
|
return `${x},${z}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 나선형으로 빈 공간 찾기
|
||||||
|
let x = 0;
|
||||||
|
let z = 0;
|
||||||
|
let direction = 0; // 0: 우, 1: 하, 2: 좌, 3: 상
|
||||||
|
let steps = 1;
|
||||||
|
let stepsTaken = 0;
|
||||||
|
let stepsInDirection = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const key = `${x},${z}`;
|
||||||
|
if (!occupiedPositions.has(key)) {
|
||||||
|
return { x, z };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 위치로 이동
|
||||||
|
stepsInDirection++;
|
||||||
|
if (direction === 0)
|
||||||
|
x += gridSize; // 우
|
||||||
|
else if (direction === 1)
|
||||||
|
z += gridSize; // 하
|
||||||
|
else if (direction === 2)
|
||||||
|
x -= gridSize; // 좌
|
||||||
|
else z -= gridSize; // 상
|
||||||
|
|
||||||
|
if (stepsInDirection >= steps) {
|
||||||
|
stepsInDirection = 0;
|
||||||
|
direction = (direction + 1) % 4;
|
||||||
|
stepsTaken++;
|
||||||
|
if (stepsTaken === 2) {
|
||||||
|
stepsTaken = 0;
|
||||||
|
steps++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: 0, z: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 특정 XZ 위치에 배치할 때 적절한 Y 위치 계산 (마인크래프트 쌓기)
|
||||||
|
const calculateYPosition = (x: number, z: number, existingPlacements: YardPlacement[]) => {
|
||||||
|
const gridSize = 5;
|
||||||
|
const halfSize = gridSize / 2;
|
||||||
|
let maxY = halfSize; // 기본 바닥 높이 (2.5)
|
||||||
|
|
||||||
|
for (const p of existingPlacements) {
|
||||||
|
// XZ가 겹치는지 확인
|
||||||
|
const isXZOverlapping = Math.abs(x - p.position_x) < gridSize && Math.abs(z - p.position_z) < gridSize;
|
||||||
|
|
||||||
|
if (isXZOverlapping) {
|
||||||
|
// 이 요소의 윗면 높이
|
||||||
|
const topY = p.position_y + (p.size_y || gridSize) / 2;
|
||||||
|
// 새 요소의 Y 위치 (윗면 + 새 요소 높이/2)
|
||||||
|
const newY = topY + gridSize / 2;
|
||||||
|
if (newY > maxY) {
|
||||||
|
maxY = newY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxY;
|
||||||
|
};
|
||||||
|
|
||||||
// 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영)
|
// 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영)
|
||||||
const handleAddElement = () => {
|
const handleAddElement = () => {
|
||||||
|
const gridSize = 5;
|
||||||
|
const emptyPos = findEmptyGridPosition(gridSize);
|
||||||
|
const centerX = emptyPos.x + gridSize / 2;
|
||||||
|
const centerZ = emptyPos.z + gridSize / 2;
|
||||||
|
|
||||||
|
// 해당 위치에 적절한 Y 계산 (쌓기)
|
||||||
|
const appropriateY = calculateYPosition(centerX, centerZ, placements);
|
||||||
|
|
||||||
const newPlacement: YardPlacement = {
|
const newPlacement: YardPlacement = {
|
||||||
id: nextPlacementId, // 임시 음수 ID
|
id: nextPlacementId, // 임시 음수 ID
|
||||||
yard_layout_id: layout.id,
|
yard_layout_id: layout.id,
|
||||||
|
|
@ -87,12 +180,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
material_name: null,
|
material_name: null,
|
||||||
quantity: null,
|
quantity: null,
|
||||||
unit: null,
|
unit: null,
|
||||||
position_x: 0,
|
// 그리드 칸의 중심에 배치 (Three.js Box position은 중심점)
|
||||||
position_y: 2.5,
|
position_x: centerX, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
|
||||||
position_z: 0,
|
position_y: appropriateY, // 쌓기 고려한 Y 위치
|
||||||
size_x: 5,
|
position_z: centerZ, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
|
||||||
size_y: 5,
|
size_x: gridSize,
|
||||||
size_z: 5,
|
size_y: gridSize,
|
||||||
|
size_z: gridSize,
|
||||||
color: "#9ca3af",
|
color: "#9ca3af",
|
||||||
data_source_type: null,
|
data_source_type: null,
|
||||||
data_source_config: null,
|
data_source_config: null,
|
||||||
|
|
@ -125,12 +219,62 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
setDeleteConfirmDialog({ open: true, placementId });
|
setDeleteConfirmDialog({ open: true, placementId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 중력 적용: 삭제된 요소 위에 있던 요소들을 아래로 내림
|
||||||
|
const applyGravity = (deletedPlacement: YardPlacement, remainingPlacements: YardPlacement[]) => {
|
||||||
|
const gridSize = 5;
|
||||||
|
const halfSize = gridSize / 2;
|
||||||
|
|
||||||
|
return remainingPlacements.map((p) => {
|
||||||
|
// 삭제된 요소와 XZ가 겹치는지 확인
|
||||||
|
const isXZOverlapping =
|
||||||
|
Math.abs(p.position_x - deletedPlacement.position_x) < gridSize &&
|
||||||
|
Math.abs(p.position_z - deletedPlacement.position_z) < gridSize;
|
||||||
|
|
||||||
|
// 삭제된 요소보다 위에 있는지 확인
|
||||||
|
const isAbove = p.position_y > deletedPlacement.position_y;
|
||||||
|
|
||||||
|
if (isXZOverlapping && isAbove) {
|
||||||
|
// 아래로 내림: 삭제된 요소의 크기만큼
|
||||||
|
const fallDistance = deletedPlacement.size_y || gridSize;
|
||||||
|
const newY = Math.max(halfSize, p.position_y - fallDistance); // 바닥(2.5) 아래로는 안 내려감
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
position_y: newY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영)
|
// 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영)
|
||||||
const confirmDeletePlacement = () => {
|
const confirmDeletePlacement = () => {
|
||||||
const { placementId } = deleteConfirmDialog;
|
const { placementId } = deleteConfirmDialog;
|
||||||
if (placementId === null) return;
|
if (placementId === null) return;
|
||||||
|
|
||||||
setPlacements((prev) => prev.filter((p) => p.id !== placementId));
|
setPlacements((prev) => {
|
||||||
|
const deletedPlacement = prev.find((p) => p.id === placementId);
|
||||||
|
if (!deletedPlacement) return prev;
|
||||||
|
|
||||||
|
// 삭제 후 남은 요소들
|
||||||
|
const remaining = prev.filter((p) => p.id !== placementId);
|
||||||
|
|
||||||
|
// 중력 적용 (재귀적으로 계속 적용)
|
||||||
|
let result = remaining;
|
||||||
|
let hasChanges = true;
|
||||||
|
|
||||||
|
// 모든 요소가 안정될 때까지 반복
|
||||||
|
while (hasChanges) {
|
||||||
|
const before = JSON.stringify(result.map((p) => p.position_y));
|
||||||
|
result = applyGravity(deletedPlacement, result);
|
||||||
|
const after = JSON.stringify(result.map((p) => p.position_y));
|
||||||
|
hasChanges = before !== after;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
if (selectedPlacement?.id === placementId) {
|
if (selectedPlacement?.id === placementId) {
|
||||||
setSelectedPlacement(null);
|
setSelectedPlacement(null);
|
||||||
setShowConfigPanel(false);
|
setShowConfigPanel(false);
|
||||||
|
|
@ -358,6 +502,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
selectedPlacementId={selectedPlacement?.id || null}
|
selectedPlacementId={selectedPlacement?.id || null}
|
||||||
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)}
|
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)}
|
||||||
onPlacementDrag={handlePlacementDrag}
|
onPlacementDrag={handlePlacementDrag}
|
||||||
|
onCollisionDetected={() => {
|
||||||
|
toast({
|
||||||
|
title: "배치 불가",
|
||||||
|
description: "해당 위치에 이미 다른 요소가 있습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -174,18 +174,6 @@ export function DashboardViewer({
|
||||||
}: DashboardViewerProps) {
|
}: DashboardViewerProps) {
|
||||||
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
|
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
|
||||||
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
|
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
|
|
||||||
// 화면 크기 감지
|
|
||||||
useEffect(() => {
|
|
||||||
const checkMobile = () => {
|
|
||||||
setIsMobile(window.innerWidth < 1024); // 1024px (lg) 미만은 모바일/태블릿
|
|
||||||
};
|
|
||||||
|
|
||||||
checkMobile();
|
|
||||||
window.addEventListener("resize", checkMobile);
|
|
||||||
return () => window.removeEventListener("resize", checkMobile);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 캔버스 설정 계산
|
// 캔버스 설정 계산
|
||||||
const canvasConfig = useMemo(() => {
|
const canvasConfig = useMemo(() => {
|
||||||
|
|
@ -287,10 +275,8 @@ export function DashboardViewer({
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [refreshInterval, loadAllData]);
|
}, [refreshInterval, loadAllData]);
|
||||||
|
|
||||||
// 모바일에서 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래)
|
// 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) - 태블릿 이하에서 세로 정렬 시 사용
|
||||||
const sortedElements = useMemo(() => {
|
const sortedElements = useMemo(() => {
|
||||||
if (!isMobile) return elements;
|
|
||||||
|
|
||||||
return [...elements].sort((a, b) => {
|
return [...elements].sort((a, b) => {
|
||||||
// Y 좌표 차이가 50px 이상이면 Y 우선 (같은 행으로 간주 안함)
|
// Y 좌표 차이가 50px 이상이면 Y 우선 (같은 행으로 간주 안함)
|
||||||
const yDiff = a.position.y - b.position.y;
|
const yDiff = a.position.y - b.position.y;
|
||||||
|
|
@ -300,7 +286,7 @@ export function DashboardViewer({
|
||||||
// 같은 행이면 X 좌표로 정렬
|
// 같은 행이면 X 좌표로 정렬
|
||||||
return a.position.x - b.position.x;
|
return a.position.x - b.position.x;
|
||||||
});
|
});
|
||||||
}, [elements, isMobile]);
|
}, [elements]);
|
||||||
|
|
||||||
// 요소가 없는 경우
|
// 요소가 없는 경우
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
|
|
@ -317,10 +303,18 @@ export function DashboardViewer({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
{isMobile ? (
|
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
||||||
// 모바일/태블릿: 세로 스택 레이아웃
|
<div className="hidden min-h-screen bg-gray-100 py-8 lg:block" style={{ backgroundColor }}>
|
||||||
<div className="min-h-screen bg-gray-100 p-4" style={{ backgroundColor }}>
|
<div className="mx-auto px-4" style={{ maxWidth: `${canvasConfig.width}px` }}>
|
||||||
<div className="mx-auto max-w-3xl space-y-4">
|
<div
|
||||||
|
className="relative rounded-lg"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: `${canvasConfig.height}px`,
|
||||||
|
height: `${canvasHeight}px`,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{sortedElements.map((element) => (
|
{sortedElements.map((element) => (
|
||||||
<ViewerElement
|
<ViewerElement
|
||||||
key={element.id}
|
key={element.id}
|
||||||
|
|
@ -328,38 +322,29 @@ export function DashboardViewer({
|
||||||
data={elementData[element.id]}
|
data={elementData[element.id]}
|
||||||
isLoading={loadingElements.has(element.id)}
|
isLoading={loadingElements.has(element.id)}
|
||||||
onRefresh={() => loadElementData(element)}
|
onRefresh={() => loadElementData(element)}
|
||||||
isMobile={true}
|
isMobile={false}
|
||||||
|
canvasWidth={canvasConfig.width}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
// 데스크톱: 기존 고정 캔버스 레이아웃
|
|
||||||
<div className="min-h-screen bg-gray-100 py-8">
|
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
||||||
<div className="mx-auto" style={{ width: `${canvasConfig.width}px` }}>
|
<div className="block min-h-screen bg-gray-100 p-4 lg:hidden" style={{ backgroundColor }}>
|
||||||
<div
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
className="relative rounded-lg"
|
{sortedElements.map((element) => (
|
||||||
style={{
|
<ViewerElement
|
||||||
width: `${canvasConfig.width}px`,
|
key={element.id}
|
||||||
minHeight: `${canvasConfig.height}px`,
|
element={element}
|
||||||
height: `${canvasHeight}px`,
|
data={elementData[element.id]}
|
||||||
backgroundColor: backgroundColor,
|
isLoading={loadingElements.has(element.id)}
|
||||||
}}
|
onRefresh={() => loadElementData(element)}
|
||||||
>
|
isMobile={true}
|
||||||
{sortedElements.map((element) => (
|
/>
|
||||||
<ViewerElement
|
))}
|
||||||
key={element.id}
|
|
||||||
element={element}
|
|
||||||
data={elementData[element.id]}
|
|
||||||
isLoading={loadingElements.has(element.id)}
|
|
||||||
onRefresh={() => loadElementData(element)}
|
|
||||||
isMobile={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</DashboardProvider>
|
</DashboardProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -370,22 +355,28 @@ interface ViewerElementProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
canvasWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개별 뷰어 요소 컴포넌트
|
* 개별 뷰어 요소 컴포넌트
|
||||||
|
* - 데스크톱(lg 이상): absolute positioning으로 디자이너에서 설정한 위치 그대로 렌더링 (너비는 화면 비율에 따라 조정)
|
||||||
|
* - 태블릿 이하: 세로 스택 카드 레이아웃
|
||||||
*/
|
*/
|
||||||
function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: ViewerElementProps) {
|
function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWidth = 1920 }: ViewerElementProps) {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
// 마운트 확인 (Leaflet 지도 초기화 문제 해결)
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
// 모바일/태블릿: 세로 스택 카드 스타일
|
// 태블릿 이하: 세로 스택 카드 스타일
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
className="relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||||
style={{ minHeight: "300px" }}
|
style={{ minHeight: "300px" }}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
>
|
>
|
||||||
{element.showHeader !== false && (
|
{element.showHeader !== false && (
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
|
|
@ -393,19 +384,31 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
|
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
|
||||||
title="새로고침"
|
title="새로고침"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<svg
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
|
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||||
) : (
|
fill="none"
|
||||||
"🔄"
|
viewBox="0 0 24 24"
|
||||||
)}
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={element.showHeader !== false ? "p-4" : "p-4"} style={{ minHeight: "250px" }}>
|
<div className={element.showHeader !== false ? "p-4" : "p-4"} style={{ minHeight: "250px" }}>
|
||||||
{element.type === "chart" ? (
|
{!isMounted ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : element.type === "chart" ? (
|
||||||
<ChartRenderer element={element} data={data} width={undefined} height={250} />
|
<ChartRenderer element={element} data={data} width={undefined} height={250} />
|
||||||
) : (
|
) : (
|
||||||
renderWidget(element)
|
renderWidget(element)
|
||||||
|
|
@ -423,18 +426,19 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데스크톱: 기존 absolute positioning
|
// 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning
|
||||||
|
// 단, 너비는 화면 크기에 따라 비율로 조정
|
||||||
|
const widthPercentage = (element.size.width / canvasWidth) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
left: element.position.x,
|
left: `${(element.position.x / canvasWidth) * 100}%`,
|
||||||
top: element.position.y,
|
top: element.position.y,
|
||||||
width: element.size.width,
|
width: `${widthPercentage}%`,
|
||||||
height: element.size.height,
|
height: element.size.height,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
>
|
>
|
||||||
{element.showHeader !== false && (
|
{element.showHeader !== false && (
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
|
|
@ -442,22 +446,37 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`text-gray-400 transition-opacity hover:text-gray-600 disabled:opacity-50 ${
|
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
|
||||||
isHovered ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
title="새로고침"
|
title="새로고침"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<svg
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
|
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||||
) : (
|
fill="none"
|
||||||
"🔄"
|
viewBox="0 0 24 24"
|
||||||
)}
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={element.showHeader !== false ? "h-[calc(100%-57px)]" : "h-full"}>
|
<div className={element.showHeader !== false ? "h-[calc(100%-50px)] w-full" : "h-full w-full"}>
|
||||||
{element.type === "chart" ? (
|
{!isMounted ? (
|
||||||
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : element.type === "chart" ? (
|
||||||
|
<ChartRenderer
|
||||||
|
element={element}
|
||||||
|
data={data}
|
||||||
|
width={undefined}
|
||||||
|
height={element.showHeader !== false ? element.size.height - 50 : element.size.height}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
renderWidget(element)
|
renderWidget(element)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
// 자동 새로고침 (30초마다)
|
// 자동 새로고침 (30초마다)
|
||||||
const interval = setInterval(loadData, 30000);
|
const interval = setInterval(loadData, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [element]);
|
}, [element]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
|
@ -101,7 +102,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query: groupByDS.query,
|
query: groupByDS.query,
|
||||||
connectionType: groupByDS.connectionType || "current",
|
connectionType: groupByDS.connectionType || "current",
|
||||||
connectionId: groupByDS.connectionId,
|
connectionId: (groupByDS as any).connectionId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -116,7 +117,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
const labelColumn = columns[0];
|
const labelColumn = columns[0];
|
||||||
const valueColumn = columns[1];
|
const valueColumn = columns[1];
|
||||||
|
|
||||||
const cards = rows.map((row) => ({
|
const cards = rows.map((row: any) => ({
|
||||||
label: String(row[labelColumn] || ""),
|
label: String(row[labelColumn] || ""),
|
||||||
value: parseFloat(row[valueColumn]) || 0,
|
value: parseFloat(row[valueColumn]) || 0,
|
||||||
}));
|
}));
|
||||||
|
|
@ -137,12 +138,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
method: groupByDS.method || "GET",
|
method: (groupByDS as any).method || "GET",
|
||||||
url: groupByDS.endpoint,
|
url: groupByDS.endpoint,
|
||||||
headers: groupByDS.headers || {},
|
headers: (groupByDS as any).headers || {},
|
||||||
body: groupByDS.body,
|
body: (groupByDS as any).body,
|
||||||
authType: groupByDS.authType,
|
authType: (groupByDS as any).authType,
|
||||||
authConfig: groupByDS.authConfig,
|
authConfig: (groupByDS as any).authConfig,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -169,7 +170,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
const labelColumn = columns[0];
|
const labelColumn = columns[0];
|
||||||
const valueColumn = columns[1];
|
const valueColumn = columns[1];
|
||||||
|
|
||||||
const cards = rows.map((row) => ({
|
const cards = rows.map((row: any) => ({
|
||||||
label: String(row[labelColumn] || ""),
|
label: String(row[labelColumn] || ""),
|
||||||
value: parseFloat(row[valueColumn]) || 0,
|
value: parseFloat(row[valueColumn]) || 0,
|
||||||
}));
|
}));
|
||||||
|
|
@ -201,7 +202,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query: element.dataSource.query,
|
query: element.dataSource.query,
|
||||||
connectionType: element.dataSource.connectionType || "current",
|
connectionType: element.dataSource.connectionType || "current",
|
||||||
connectionId: element.dataSource.connectionId,
|
connectionId: (element.dataSource as any).connectionId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -212,13 +213,14 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
if (result.success && result.data?.rows) {
|
if (result.success && result.data?.rows) {
|
||||||
const rows = result.data.rows;
|
const rows = result.data.rows;
|
||||||
|
|
||||||
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
|
const calculatedMetrics =
|
||||||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
element.customMetricConfig?.metrics.map((metric) => {
|
||||||
return {
|
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
||||||
...metric,
|
return {
|
||||||
calculatedValue: value,
|
...metric,
|
||||||
};
|
calculatedValue: value,
|
||||||
});
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
setMetrics(calculatedMetrics);
|
setMetrics(calculatedMetrics);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -240,12 +242,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
method: element.dataSource.method || "GET",
|
method: (element.dataSource as any).method || "GET",
|
||||||
url: element.dataSource.endpoint,
|
url: element.dataSource.endpoint,
|
||||||
headers: element.dataSource.headers || {},
|
headers: (element.dataSource as any).headers || {},
|
||||||
body: element.dataSource.body,
|
body: (element.dataSource as any).body,
|
||||||
authType: element.dataSource.authType,
|
authType: (element.dataSource as any).authType,
|
||||||
authConfig: element.dataSource.authConfig,
|
authConfig: (element.dataSource as any).authConfig,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -278,13 +280,14 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
rows = [result.data];
|
rows = [result.data];
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
|
const calculatedMetrics =
|
||||||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
element.customMetricConfig?.metrics.map((metric) => {
|
||||||
return {
|
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
||||||
...metric,
|
return {
|
||||||
calculatedValue: value,
|
...metric,
|
||||||
};
|
calculatedValue: value,
|
||||||
});
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
setMetrics(calculatedMetrics);
|
setMetrics(calculatedMetrics);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -351,7 +354,9 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
<li>• 선택한 컬럼의 데이터로 지표를 계산합니다</li>
|
<li>• 선택한 컬럼의 데이터로 지표를 계산합니다</li>
|
||||||
<li>• COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원</li>
|
<li>• COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원</li>
|
||||||
<li>• 사용자 정의 단위 설정 가능</li>
|
<li>• 사용자 정의 단위 설정 가능</li>
|
||||||
<li>• <strong>그룹별 카드 생성 모드</strong>로 간편하게 사용 가능</li>
|
<li>
|
||||||
|
• <strong>그룹별 카드 생성 모드</strong>로 간편하게 사용 가능
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||||
|
|
@ -361,11 +366,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
|
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
|
||||||
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
|
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
|
||||||
</p>
|
</p>
|
||||||
{isGroupByMode && (
|
{isGroupByMode && <p className="text-[9px]">💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값</p>}
|
||||||
<p className="text-[9px]">
|
|
||||||
💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -373,42 +374,46 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden bg-white p-4">
|
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||||
{/* 스크롤 가능한 콘텐츠 영역 */}
|
{/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="grid h-full w-full gap-2" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}>
|
||||||
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
|
{/* 그룹별 카드 (활성화 시) */}
|
||||||
{/* 그룹별 카드 (활성화 시) */}
|
{isGroupByMode &&
|
||||||
{isGroupByMode &&
|
groupedCards.map((card, index) => {
|
||||||
groupedCards.map((card, index) => {
|
// 색상 순환 (6가지 색상)
|
||||||
// 색상 순환 (6가지 색상)
|
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
const colorKey = colorKeys[index % colorKeys.length];
|
||||||
const colorKey = colorKeys[index % colorKeys.length];
|
const colors = colorMap[colorKey];
|
||||||
const colors = colorMap[colorKey];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={`group-${index}`} className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}>
|
|
||||||
<div className="text-sm text-gray-600">{card.label}</div>
|
|
||||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* 일반 지표 카드 (항상 표시) */}
|
|
||||||
{metrics.map((metric) => {
|
|
||||||
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
|
||||||
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={metric.id} className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}>
|
<div
|
||||||
<div className="text-sm text-gray-600">{metric.label}</div>
|
key={`group-${index}`}
|
||||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
||||||
{formattedValue}
|
>
|
||||||
<span className="ml-1 text-lg">{metric.unit}</span>
|
<div className="text-[10px] text-gray-600">{card.label}</div>
|
||||||
</div>
|
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
|
||||||
|
{/* 일반 지표 카드 (항상 표시) */}
|
||||||
|
{metrics.map((metric) => {
|
||||||
|
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||||||
|
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={metric.id}
|
||||||
|
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-gray-600">{metric.label}</div>
|
||||||
|
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>
|
||||||
|
{formattedValue}
|
||||||
|
<span className="ml-0.5 text-sm">{metric.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,14 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||||
flowData.nodes || [],
|
flowData.nodes || [],
|
||||||
flowData.edges || [],
|
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) {
|
} catch (error) {
|
||||||
console.error("플로우 로드 실패:", error);
|
console.error("플로우 로드 실패:", error);
|
||||||
|
|
@ -155,7 +163,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAndLoadFlow();
|
fetchAndLoadFlow();
|
||||||
}, [initialFlowId]);
|
}, [initialFlowId, loadFlow, selectNodes]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 노드 선택 변경 핸들러
|
* 노드 선택 변경 핸들러
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,19 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||||
const [tablesOpen, setTablesOpen] = useState(false);
|
const [tablesOpen, setTablesOpen] = useState(false);
|
||||||
const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable);
|
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(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
||||||
setTargetTable(data.targetTable);
|
setTargetTable(data.targetTable);
|
||||||
|
|
@ -101,6 +114,18 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||||
}
|
}
|
||||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
}, [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 () => {
|
const loadExternalConnections = async () => {
|
||||||
try {
|
try {
|
||||||
setExternalConnectionsLoading(true);
|
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 handleTableSelect = (tableName: string) => {
|
||||||
const selectedTable = tables.find((t: any) => t.tableName === tableName);
|
const selectedTable = tables.find((t: any) => t.tableName === tableName);
|
||||||
const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName;
|
const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName;
|
||||||
|
|
@ -186,18 +233,22 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddCondition = () => {
|
const handleAddCondition = () => {
|
||||||
setWhereConditions([
|
const newConditions = [
|
||||||
...whereConditions,
|
...whereConditions,
|
||||||
{
|
{
|
||||||
field: "",
|
field: "",
|
||||||
operator: "EQUALS",
|
operator: "EQUALS",
|
||||||
value: "",
|
value: "",
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
setWhereConditions(newConditions);
|
||||||
|
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveCondition = (index: number) => {
|
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) => {
|
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||||
|
|
@ -639,64 +690,169 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{whereConditions.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{whereConditions.map((condition, index) => (
|
{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">
|
const availableColumns =
|
||||||
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
targetType === "internal"
|
||||||
<Button
|
? targetColumns
|
||||||
size="sm"
|
: targetType === "external"
|
||||||
variant="ghost"
|
? externalColumns.map((col) => ({
|
||||||
onClick={() => handleRemoveCondition(index)}
|
columnName: col.column_name,
|
||||||
className="h-6 w-6 p-0"
|
columnLabel: col.column_name,
|
||||||
>
|
dataType: col.data_type,
|
||||||
<Trash2 className="h-3 w-3" />
|
isNullable: true,
|
||||||
</Button>
|
}))
|
||||||
</div>
|
: [];
|
||||||
|
|
||||||
<div className="space-y-2">
|
return (
|
||||||
<div>
|
<div key={index} className="rounded border-2 border-red-200 bg-red-50 p-2">
|
||||||
<Label className="text-xs text-gray-600">필드</Label>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<Input
|
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
||||||
value={condition.field}
|
<Button
|
||||||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
size="sm"
|
||||||
placeholder="조건 필드명"
|
variant="ghost"
|
||||||
className="mt-1 h-8 text-xs"
|
onClick={() => handleRemoveCondition(index)}
|
||||||
/>
|
className="h-6 w-6 p-0"
|
||||||
</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">
|
<Trash2 className="h-3 w-3" />
|
||||||
<SelectValue />
|
</Button>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{OPERATORS.map((op) => (
|
|
||||||
<SelectItem key={op.value} value={op.value}>
|
|
||||||
{op.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label className="text-xs text-gray-600">값</Label>
|
{/* 필드 - Combobox */}
|
||||||
<Input
|
<div>
|
||||||
value={condition.value as string}
|
<Label className="text-xs text-gray-600">필드</Label>
|
||||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
{availableColumns.length > 0 ? (
|
||||||
placeholder="비교 값"
|
<Popover
|
||||||
className="mt-1 h-8 text-xs"
|
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>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded border-2 border-dashed border-red-300 bg-red-50 p-4 text-center text-xs text-red-600">
|
<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>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
||||||
🚨 WHERE 조건 없이 삭제하면 테이블의 모든 데이터가 영구 삭제됩니다!
|
🚨 WHERE 조건 없이 삭제하면 테이블의 모든 데이터가 영구 삭제됩니다!
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
|
@ -37,6 +38,7 @@ interface ColumnInfo {
|
||||||
columnLabel?: string;
|
columnLabel?: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
isNullable: boolean;
|
isNullable: boolean;
|
||||||
|
columnDefault?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) {
|
export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) {
|
||||||
|
|
@ -63,6 +65,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
// REST API 소스 노드 연결 여부
|
// REST API 소스 노드 연결 여부
|
||||||
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||||||
|
|
||||||
|
// Combobox 열림 상태 관리 (필드 매핑)
|
||||||
|
const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState<boolean[]>([]);
|
||||||
|
const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState<boolean[]>([]);
|
||||||
|
|
||||||
// 🔥 외부 DB 관련 상태
|
// 🔥 외부 DB 관련 상태
|
||||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||||
|
|
@ -118,6 +124,12 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
}
|
}
|
||||||
}, [targetType, selectedExternalConnectionId]);
|
}, [targetType, selectedExternalConnectionId]);
|
||||||
|
|
||||||
|
// fieldMappings 변경 시 Combobox 열림 상태 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
|
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
|
}, [fieldMappings.length]);
|
||||||
|
|
||||||
// 🔥 외부 테이블 변경 시 컬럼 로드
|
// 🔥 외부 테이블 변경 시 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||||
|
|
@ -340,12 +352,27 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
|
|
||||||
const columns = await tableTypeApi.getColumns(tableName);
|
const columns = await tableTypeApi.getColumns(tableName);
|
||||||
|
|
||||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
const columnInfo: ColumnInfo[] = columns.map((col: any) => {
|
||||||
columnName: col.column_name || col.columnName,
|
// is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환
|
||||||
columnLabel: col.label_ko || col.columnLabel,
|
const isNullableValue = col.is_nullable ?? col.isNullable;
|
||||||
dataType: col.data_type || col.dataType || "unknown",
|
let isNullable = true; // 기본값: nullable
|
||||||
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
|
||||||
}));
|
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);
|
setTargetColumns(columnInfo);
|
||||||
console.log(`✅ 컬럼 ${columnInfo.length}개 로딩 완료`);
|
console.log(`✅ 컬럼 ${columnInfo.length}개 로딩 완료`);
|
||||||
|
|
@ -449,12 +476,20 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
];
|
];
|
||||||
setFieldMappings(newMappings);
|
setFieldMappings(newMappings);
|
||||||
updateNode(nodeId, { fieldMappings: 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 handleRemoveMapping = (index: number) => {
|
||||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||||
setFieldMappings(newMappings);
|
setFieldMappings(newMappings);
|
||||||
updateNode(nodeId, { fieldMappings: 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) => {
|
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"
|
className="mt-1 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// 일반 소스인 경우: 드롭다운 선택
|
// 일반 소스인 경우: Combobox 선택
|
||||||
<Select
|
<Popover
|
||||||
value={mapping.sourceField || ""}
|
open={mappingSourceFieldsOpenState[index]}
|
||||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
onOpenChange={(open) => {
|
||||||
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
|
newState[index] = open;
|
||||||
|
setMappingSourceFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder="소스 필드 선택" />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
{sourceFields.length === 0 ? (
|
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||||
<div className="p-2 text-center text-xs text-gray-400">
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
연결된 소스 노드가 없습니다
|
>
|
||||||
</div>
|
{mapping.sourceField
|
||||||
) : (
|
? (() => {
|
||||||
sourceFields.map((field) => (
|
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
<span className="truncate font-medium">
|
||||||
{field.label && field.label !== field.name && (
|
{field?.label || mapping.sourceField}
|
||||||
<span className="text-muted-foreground font-mono text-xs">
|
</span>
|
||||||
{field.name}
|
{field?.label && field.label !== field.name && (
|
||||||
</span>
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
)}
|
{field.name}
|
||||||
</div>
|
</span>
|
||||||
</SelectItem>
|
)}
|
||||||
))
|
</div>
|
||||||
)}
|
);
|
||||||
</SelectContent>
|
})()
|
||||||
</Select>
|
: "소스 필드 선택"}
|
||||||
|
<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 && (
|
{hasRestAPISource && (
|
||||||
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
<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" />
|
<ArrowRight className="h-4 w-4 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 타겟 필드 드롭다운 (🔥 타입별 컬럼 사용) */}
|
{/* 타겟 필드 Combobox (🔥 타입별 컬럼 사용) */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||||
<Select
|
<Popover
|
||||||
value={mapping.targetField}
|
open={mappingTargetFieldsOpenState[index]}
|
||||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
onOpenChange={(open) => {
|
||||||
|
const newState = [...mappingTargetFieldsOpenState];
|
||||||
|
newState[index] = open;
|
||||||
|
setMappingTargetFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder="타겟 필드 선택" />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
{/* 🔥 내부 DB 컬럼 */}
|
aria-expanded={mappingTargetFieldsOpenState[index]}
|
||||||
{targetType === "internal" &&
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
targetColumns.map((col) => (
|
>
|
||||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
{mapping.targetField
|
||||||
<div className="flex items-center justify-between gap-2">
|
? (() => {
|
||||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
if (targetType === "internal") {
|
||||||
<span className="text-muted-foreground">
|
const col = targetColumns.find((c) => c.columnName === mapping.targetField);
|
||||||
{col.dataType}
|
return (
|
||||||
{!col.isNullable && <span className="text-red-500">*</span>}
|
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||||
</span>
|
<span className="truncate font-mono">
|
||||||
</div>
|
{col?.columnLabel || mapping.targetField}
|
||||||
</SelectItem>
|
</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 컬럼 */}
|
{/* 🔥 외부 DB 컬럼 */}
|
||||||
{targetType === "external" &&
|
{targetType === "external" &&
|
||||||
externalColumns.map((col) => (
|
externalColumns.map((col) => (
|
||||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
<CommandItem
|
||||||
<div className="flex items-center justify-between gap-2">
|
key={col.column_name}
|
||||||
<span className="font-mono">{col.column_name}</span>
|
value={col.column_name}
|
||||||
<span className="text-muted-foreground">{col.data_type}</span>
|
onSelect={(currentValue) => {
|
||||||
</div>
|
handleMappingChange(index, "targetField", currentValue);
|
||||||
</SelectItem>
|
const newState = [...mappingTargetFieldsOpenState];
|
||||||
))}
|
newState[index] = false;
|
||||||
</SelectContent>
|
setMappingTargetFieldsOpenState(newState);
|
||||||
</Select>
|
}}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 정적 값 */}
|
{/* 정적 값 */}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
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 { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Check } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
||||||
|
|
@ -62,6 +61,9 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
||||||
const [referenceColumns, setReferenceColumns] = useState<FieldDefinition[]>([]);
|
const [referenceColumns, setReferenceColumns] = useState<FieldDefinition[]>([]);
|
||||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Combobox 열림 상태 관리
|
||||||
|
const [whereFieldOpenState, setWhereFieldOpenState] = useState<boolean[]>([]);
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 동기화
|
// 데이터 변경 시 로컬 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || "참조 조회");
|
setDisplayName(data.displayName || "참조 조회");
|
||||||
|
|
@ -72,6 +74,11 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
||||||
setOutputFields(data.outputFields || []);
|
setOutputFields(data.outputFields || []);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// whereConditions 변경 시 whereFieldOpenState 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
setWhereFieldOpenState(new Array(whereConditions.length).fill(false));
|
||||||
|
}, [whereConditions.length]);
|
||||||
|
|
||||||
// 🔍 소스 필드 수집 (업스트림 노드에서)
|
// 🔍 소스 필드 수집 (업스트림 노드에서)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
||||||
|
|
@ -187,7 +194,7 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
||||||
|
|
||||||
// WHERE 조건 추가
|
// WHERE 조건 추가
|
||||||
const handleAddWhereCondition = () => {
|
const handleAddWhereCondition = () => {
|
||||||
setWhereConditions([
|
const newConditions = [
|
||||||
...whereConditions,
|
...whereConditions,
|
||||||
{
|
{
|
||||||
field: "",
|
field: "",
|
||||||
|
|
@ -195,11 +202,15 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
||||||
value: "",
|
value: "",
|
||||||
valueType: "static",
|
valueType: "static",
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
setWhereConditions(newConditions);
|
||||||
|
setWhereFieldOpenState(new Array(newConditions.length).fill(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveWhereCondition = (index: number) => {
|
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) => {
|
const handleWhereConditionChange = (index: number, field: string, value: any) => {
|
||||||
|
|
@ -455,23 +466,81 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/* 필드 - Combobox */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">필드</Label>
|
<Label className="text-xs text-gray-600">필드</Label>
|
||||||
<Select
|
<Popover
|
||||||
value={condition.field}
|
open={whereFieldOpenState[index]}
|
||||||
onValueChange={(value) => handleWhereConditionChange(index, "field", value)}
|
onOpenChange={(open) => {
|
||||||
|
const newState = [...whereFieldOpenState];
|
||||||
|
newState[index] = open;
|
||||||
|
setWhereFieldOpenState(newState);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder="필드 선택" />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
{referenceColumns.map((field) => (
|
aria-expanded={whereFieldOpenState[index]}
|
||||||
<SelectItem key={field.name} value={field.name}>
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
{field.label || field.name}
|
>
|
||||||
</SelectItem>
|
{condition.field
|
||||||
))}
|
? (() => {
|
||||||
</SelectContent>
|
const field = referenceColumns.find((f) => f.name === condition.field);
|
||||||
</Select>
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,6 +37,7 @@ interface ColumnInfo {
|
||||||
columnLabel?: string;
|
columnLabel?: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
isNullable: boolean;
|
isNullable: boolean;
|
||||||
|
columnDefault?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) {
|
export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) {
|
||||||
|
|
@ -85,6 +86,11 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
// REST API 소스 노드 연결 여부
|
// REST API 소스 노드 연결 여부
|
||||||
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||||||
|
|
||||||
|
// Combobox 열림 상태 관리
|
||||||
|
const [conflictKeysOpenState, setConflictKeysOpenState] = useState<boolean[]>([]);
|
||||||
|
const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState<boolean[]>([]);
|
||||||
|
const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState<boolean[]>([]);
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 업데이트
|
// 데이터 변경 시 로컬 상태 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || data.targetTable);
|
setDisplayName(data.displayName || data.targetTable);
|
||||||
|
|
@ -129,6 +135,17 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
}
|
}
|
||||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const getAllSourceFields = (
|
const getAllSourceFields = (
|
||||||
|
|
@ -326,12 +343,27 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
setColumnsLoading(true);
|
setColumnsLoading(true);
|
||||||
const columns = await tableTypeApi.getColumns(tableName);
|
const columns = await tableTypeApi.getColumns(tableName);
|
||||||
|
|
||||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
const columnInfo: ColumnInfo[] = columns.map((col: any) => {
|
||||||
columnName: col.column_name || col.columnName,
|
// is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환
|
||||||
columnLabel: col.label_ko || col.columnLabel,
|
const isNullableValue = col.is_nullable ?? col.isNullable;
|
||||||
dataType: col.data_type || col.dataType || "unknown",
|
let isNullable = true; // 기본값: nullable
|
||||||
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
|
||||||
}));
|
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);
|
setTargetColumns(columnInfo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -401,12 +433,20 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
];
|
];
|
||||||
setFieldMappings(newMappings);
|
setFieldMappings(newMappings);
|
||||||
updateNode(nodeId, { fieldMappings: 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 handleRemoveMapping = (index: number) => {
|
||||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||||
setFieldMappings(newMappings);
|
setFieldMappings(newMappings);
|
||||||
updateNode(nodeId, { fieldMappings: 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) => {
|
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||||
|
|
@ -934,24 +974,46 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 충돌 키 추가 드롭다운 */}
|
{/* 충돌 키 추가 Combobox */}
|
||||||
<Select onValueChange={handleAddConflictKey}>
|
<Popover>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder="충돌 키 추가..." />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
{targetColumns
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
.filter((col) => !conflictKeys.includes(col.columnName))
|
>
|
||||||
.map((col) => (
|
충돌 키 추가...
|
||||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<div className="flex items-center justify-between gap-2">
|
</Button>
|
||||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
</PopoverTrigger>
|
||||||
<span className="text-muted-foreground">{col.dataType}</span>
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
</div>
|
<Command>
|
||||||
</SelectItem>
|
<CommandInput placeholder="충돌 키 검색..." className="text-xs sm:text-sm" />
|
||||||
))}
|
<CommandList>
|
||||||
</SelectContent>
|
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||||
</Select>
|
<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>
|
</div>
|
||||||
|
|
@ -1010,32 +1072,84 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
className="mt-1 h-8 text-xs"
|
className="mt-1 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Popover
|
||||||
value={mapping.sourceField || ""}
|
open={mappingSourceFieldsOpenState[index]}
|
||||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
onOpenChange={(open) => {
|
||||||
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
|
newState[index] = open;
|
||||||
|
setMappingSourceFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder="소스 필드 선택" />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
{sourceFields.length === 0 ? (
|
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||||
<div className="p-2 text-center text-xs text-gray-400">
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
연결된 소스 노드가 없습니다
|
>
|
||||||
</div>
|
{mapping.sourceField
|
||||||
) : (
|
? (() => {
|
||||||
sourceFields.map((field) => (
|
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
<span className="truncate font-medium">
|
||||||
{field.label && field.label !== field.name && (
|
{field?.label || mapping.sourceField}
|
||||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
</span>
|
||||||
)}
|
{field?.label && field.label !== field.name && (
|
||||||
</div>
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
</SelectItem>
|
{field.name}
|
||||||
))
|
</span>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
);
|
||||||
|
})()
|
||||||
|
: "소스 필드 선택"}
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1043,30 +1157,91 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
<ArrowRight className="h-4 w-4 text-purple-600" />
|
<ArrowRight className="h-4 w-4 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 타겟 필드 드롭다운 */}
|
{/* 타겟 필드 Combobox */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||||
<Select
|
<Popover
|
||||||
value={mapping.targetField}
|
open={mappingTargetFieldsOpenState[index]}
|
||||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
onOpenChange={(open) => {
|
||||||
|
const newState = [...mappingTargetFieldsOpenState];
|
||||||
|
newState[index] = open;
|
||||||
|
setMappingTargetFieldsOpenState(newState);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder="타겟 필드 선택" />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
{targetColumns.map((col) => (
|
aria-expanded={mappingTargetFieldsOpenState[index]}
|
||||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
<div className="flex items-center justify-between gap-2">
|
>
|
||||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
{mapping.targetField
|
||||||
<span className="text-muted-foreground">
|
? (() => {
|
||||||
{col.dataType}
|
const col = targetColumns.find((c) => c.columnName === mapping.targetField);
|
||||||
{!col.isNullable && <span className="text-red-500">*</span>}
|
return (
|
||||||
</span>
|
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||||
</div>
|
<span className="truncate font-mono">
|
||||||
</SelectItem>
|
{col?.columnLabel || mapping.targetField}
|
||||||
))}
|
</span>
|
||||||
</SelectContent>
|
<span className="text-muted-foreground text-xs">
|
||||||
</Select>
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* 정적 값 */}
|
{/* 정적 값 */}
|
||||||
|
|
@ -1092,7 +1267,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import type { NodePaletteItem } from "@/types/node-editor";
|
||||||
|
|
||||||
export function NodePalette() {
|
export function NodePalette() {
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
new Set(["source", "transform", "action", "utility"]),
|
new Set(), // 기본적으로 모든 아코디언 닫힘
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleCategory = (categoryId: string) => {
|
const toggleCategory = (categoryId: string) => {
|
||||||
|
|
|
||||||
|
|
@ -416,7 +416,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
} flex h-[calc(100vh-3.5rem)] w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
} flex h-[calc(100vh-3.5rem)] w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
||||||
>
|
>
|
||||||
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
||||||
{(user as ExtendedUserInfo)?.userType === "admin" && (
|
{(user as ExtendedUserInfo)?.userType?.toLowerCase().includes("admin") && (
|
||||||
<div className="border-b border-slate-200 p-3">
|
<div className="border-b border-slate-200 p-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleModeSwitch}
|
onClick={handleModeSwitch}
|
||||||
|
|
|
||||||
|
|
@ -3076,7 +3076,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 일반 컴포넌트만 격자 스냅 적용 (그룹 제외)
|
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
|
||||||
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
|
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
|
||||||
finalPosition = snapToGrid(
|
finalPosition = snapToGrid(
|
||||||
{
|
{
|
||||||
|
|
@ -3094,6 +3094,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("🎯 격자 스냅 적용됨:", {
|
console.log("🎯 격자 스냅 적용됨:", {
|
||||||
|
componentType: draggedComponent?.type,
|
||||||
resolution: `${screenResolution.width}x${screenResolution.height}`,
|
resolution: `${screenResolution.width}x${screenResolution.height}`,
|
||||||
originalPosition: dragState.currentPosition,
|
originalPosition: dragState.currentPosition,
|
||||||
snappedPosition: finalPosition,
|
snappedPosition: finalPosition,
|
||||||
|
|
@ -3516,12 +3517,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 고유한 그룹 ID 생성
|
// 고유한 그룹 ID 생성
|
||||||
const newGroupId = generateGroupId();
|
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 groupedButtons = selectedComponents.map((button) => {
|
||||||
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {};
|
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {};
|
||||||
|
|
||||||
|
// 모든 버튼을 그룹 시작점에 배치
|
||||||
|
// FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨
|
||||||
|
const newPosition = {
|
||||||
|
x: groupX,
|
||||||
|
y: groupY,
|
||||||
|
z: button.position.z || 1,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...button,
|
...button,
|
||||||
|
position: newPosition,
|
||||||
webTypeConfig: {
|
webTypeConfig: {
|
||||||
...(button as any).webTypeConfig,
|
...(button as any).webTypeConfig,
|
||||||
flowVisibilityConfig: {
|
flowVisibilityConfig: {
|
||||||
|
|
@ -3558,7 +3626,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
console.log("✅ 플로우 버튼 그룹 생성 완료:", {
|
console.log("✅ 플로우 버튼 그룹 생성 완료:", {
|
||||||
groupId: newGroupId,
|
groupId: newGroupId,
|
||||||
buttonCount: selectedComponents.length,
|
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,
|
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">
|
<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)}%
|
🔍 {Math.round(zoomLevel * 100)}%
|
||||||
</div>
|
</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">
|
const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs text-gray-600">
|
|
||||||
<svg
|
// 버튼 컴포넌트만 필터링
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
|
||||||
width="14"
|
|
||||||
height="14"
|
// 플로우 그룹에 속한 버튼이 있는지 확인
|
||||||
viewBox="0 0 24 24"
|
const hasFlowGroupButton = selectedButtons.some((btn) => {
|
||||||
fill="none"
|
const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
|
||||||
stroke="currentColor"
|
return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
|
||||||
strokeWidth="2"
|
});
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
// 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
|
||||||
>
|
const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
|
||||||
<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>
|
if (!shouldShow) return null;
|
||||||
<line x1="12" y1="22" x2="12" y2="12"></line>
|
|
||||||
</svg>
|
return (
|
||||||
<span className="font-medium">{groupState.selectedComponents.length}개 선택됨</span>
|
<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>
|
</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>
|
);
|
||||||
)}
|
})()}
|
||||||
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
|
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
|
||||||
<div
|
<div
|
||||||
className="flex justify-center"
|
className="flex justify-center"
|
||||||
|
|
@ -4707,20 +4799,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const groupConfig = (firstButton as any).webTypeConfig
|
const groupConfig = (firstButton as any).webTypeConfig
|
||||||
?.flowVisibilityConfig as FlowVisibilityConfig;
|
?.flowVisibilityConfig as FlowVisibilityConfig;
|
||||||
|
|
||||||
// 🔧 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
|
// 🔧 그룹의 위치 및 크기 계산
|
||||||
const groupPosition = buttons.reduce(
|
// 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로
|
||||||
(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 direction = groupConfig.groupDirection || "horizontal";
|
||||||
const gap = groupConfig.groupGap ?? 8;
|
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 groupWidth = 0;
|
||||||
let groupHeight = 0;
|
let groupHeight = 0;
|
||||||
|
|
||||||
|
|
@ -4731,12 +4823,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const gapWidth = index < buttons.length - 1 ? gap : 0;
|
const gapWidth = index < buttons.length - 1 ? gap : 0;
|
||||||
return total + buttonWidth + gapWidth;
|
return total + buttonWidth + gapWidth;
|
||||||
}, 0);
|
}, 0);
|
||||||
// 세로는 가장 큰 버튼의 높이
|
|
||||||
groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
|
groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
|
||||||
} else {
|
} else {
|
||||||
// 세로 정렬: 가로는 가장 큰 버튼의 너비
|
// 세로 정렬
|
||||||
groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
|
groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
|
||||||
// 세로는 모든 버튼의 높이 + 간격
|
|
||||||
groupHeight = buttons.reduce((total, button, index) => {
|
groupHeight = buttons.reduce((total, button, index) => {
|
||||||
const buttonHeight = button.size?.height || 40;
|
const buttonHeight = button.size?.height || 40;
|
||||||
const gapHeight = index < buttons.length - 1 ? gap : 0;
|
const gapHeight = index < buttons.length - 1 ? gap : 0;
|
||||||
|
|
@ -4744,6 +4834,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}, 0);
|
}, 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`flow-button-group-${groupId}`}
|
key={`flow-button-group-${groupId}`}
|
||||||
|
|
@ -4754,7 +4852,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
zIndex: groupPosition.z,
|
zIndex: groupPosition.z,
|
||||||
width: `${groupWidth}px`, // 🆕 명시적 너비
|
width: `${groupWidth}px`, // 🆕 명시적 너비
|
||||||
height: `${groupHeight}px`, // 🆕 명시적 높이
|
height: `${groupHeight}px`, // 🆕 명시적 높이
|
||||||
|
pointerEvents: "none", // 그룹 컨테이너는 이벤트 차단하여 개별 버튼 클릭 가능
|
||||||
}}
|
}}
|
||||||
|
className={hasAnySelected ? "rounded outline-2 outline-offset-2 outline-blue-500" : ""}
|
||||||
>
|
>
|
||||||
<FlowButtonGroup
|
<FlowButtonGroup
|
||||||
buttons={buttons}
|
buttons={buttons}
|
||||||
|
|
@ -4805,10 +4905,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
width: button.size?.width || 100,
|
width: button.size?.width || 100,
|
||||||
height: button.size?.height || 40,
|
height: button.size?.height || 40,
|
||||||
|
pointerEvents: "auto", // 개별 버튼은 이벤트 활성화
|
||||||
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onMouseDown={(e) => {
|
||||||
|
// 클릭이 아닌 드래그인 경우에만 드래그 시작
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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) => {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -4817,12 +4969,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
className={
|
className={
|
||||||
selectedComponent?.id === button.id ||
|
selectedComponent?.id === button.id ||
|
||||||
groupState.selectedComponents.includes(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로 직접 렌더링 */}
|
{/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
|
||||||
<div style={{ width: "100%", height: "100%" }}>
|
<div style={{ width: "100%", height: "100%", pointerEvents: "none" }}>
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
component={relativeButton}
|
component={relativeButton}
|
||||||
isDesignMode={true}
|
isDesignMode={true}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,32 @@ export function FlowWidget({
|
||||||
setStepCounts(countsMap);
|
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) {
|
} catch (err: any) {
|
||||||
console.error("Failed to load flow data:", err);
|
console.error("Failed to load flow data:", err);
|
||||||
|
|
@ -732,12 +758,12 @@ export function FlowWidget({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */}
|
{/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */}
|
||||||
<div className="hidden overflow-auto @sm:block" style={{ height: "450px" }}>
|
<div className="relative hidden overflow-auto @sm:block" style={{ height: "450px" }}>
|
||||||
<Table>
|
<Table noWrapper>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
<TableRow className="hover:bg-muted/50">
|
||||||
{allowDataMove && (
|
{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
|
<Checkbox
|
||||||
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
||||||
onCheckedChange={toggleAllRows}
|
onCheckedChange={toggleAllRows}
|
||||||
|
|
@ -747,7 +773,7 @@ export function FlowWidget({
|
||||||
{stepDataColumns.map((col) => (
|
{stepDataColumns.map((col) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={col}
|
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}
|
{col}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue