Compare commits
7 Commits
9994a47e54
...
9f3437d499
| Author | SHA1 | Date |
|---|---|---|
|
|
9f3437d499 | |
|
|
dd867efd0a | |
|
|
1de67a88b5 | |
|
|
f3a0c92564 | |
|
|
32139beebc | |
|
|
593209e26e | |
|
|
942eb079e8 |
|
|
@ -845,6 +845,9 @@ export class NodeFlowExecutionService {
|
||||||
logger.info(
|
logger.info(
|
||||||
`📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건`
|
`📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건`
|
||||||
);
|
);
|
||||||
|
// 🔍 디버깅: sourceData 내용 출력
|
||||||
|
logger.info(`📊 [테이블소스] sourceData 필드: ${JSON.stringify(Object.keys(context.sourceData[0]))}`);
|
||||||
|
logger.info(`📊 [테이블소스] sourceData.sabun: ${context.sourceData[0]?.sabun}`);
|
||||||
return context.sourceData;
|
return context.sourceData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ services:
|
||||||
- "9771:3000"
|
- "9771:3000"
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||||
|
- NODE_OPTIONS=--max-old-space-size=8192
|
||||||
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
volumes:
|
volumes:
|
||||||
- ../../frontend:/app
|
- ../../frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
# 저장 후 플로우 실행 시 폼 데이터 전달 오류 수정
|
||||||
|
|
||||||
|
## 오류 현상
|
||||||
|
|
||||||
|
사용자가 폼에서 데이터를 저장한 후, 연결된 노드 플로우(예: 비밀번호 자동 설정)가 실행될 때 `sabun` 값이 `undefined`로 전달되어 UPDATE 쿼리의 WHERE 조건이 작동하지 않는 문제.
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 저장 버튼 클릭 시 INSERT는 정상 작동
|
||||||
|
- 저장 후 실행되는 노드 플로우에서 `user_password` UPDATE가 실패 (0건 업데이트)
|
||||||
|
- 콘솔 로그에서 `savedData.sabun: undefined` 출력
|
||||||
|
|
||||||
|
```
|
||||||
|
📦 [executeAfterSaveControl] savedData 필드: ['id', 'screenId', 'tableName', 'data', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']
|
||||||
|
📦 [executeAfterSaveControl] savedData.sabun: undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 원인 분석
|
||||||
|
|
||||||
|
### API 응답 구조의 3단계 중첩
|
||||||
|
|
||||||
|
저장 API(`DynamicFormApi.saveFormData`)의 응답이 3단계로 중첩되어 있었음:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1단계: Axios 응답
|
||||||
|
saveResult = {
|
||||||
|
data: { ... } // API 응답
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2단계: API 응답 래핑 (ApiResponse 인터페이스)
|
||||||
|
saveResult.data = {
|
||||||
|
success: true,
|
||||||
|
data: { ... }, // 저장된 레코드
|
||||||
|
message: "저장 완료"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3단계: 저장된 레코드 (dynamic_form_data 테이블 구조)
|
||||||
|
saveResult.data.data = {
|
||||||
|
id: 123,
|
||||||
|
screenId: 106,
|
||||||
|
tableName: "user_info",
|
||||||
|
data: { sabun: "20260205-087", user_name: "TEST", ... }, // ← 실제 폼 데이터
|
||||||
|
createdAt: "2026-02-05T...",
|
||||||
|
updatedAt: "2026-02-05T...",
|
||||||
|
createdBy: "admin",
|
||||||
|
updatedBy: "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4단계: 실제 폼 데이터 (우리가 필요한 데이터)
|
||||||
|
saveResult.data.data.data = {
|
||||||
|
sabun: "20260205-087",
|
||||||
|
user_name: "TEST",
|
||||||
|
user_id: "Kim1542",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 기존 코드의 문제점
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 코드 (buttonActions.ts:1619-1621)
|
||||||
|
const savedData = saveResult?.data?.data || saveResult?.data || {};
|
||||||
|
const formData = savedData; // ← 2단계까지만 추출
|
||||||
|
|
||||||
|
// savedData = { id, screenId, tableName, data: {...}, createdAt, ... }
|
||||||
|
// savedData.sabun = undefined ← 문제 발생!
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 코드는 2단계(`saveResult.data.data`)까지만 추출했기 때문에, `savedData`가 저장된 레코드 메타데이터를 가리키고 있었음. 실제 폼 데이터는 `savedData.data` 안에 있었음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 해결 방법
|
||||||
|
|
||||||
|
### 수정된 코드
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 수정된 코드 (buttonActions.ts:1619-1628)
|
||||||
|
// 🔧 수정: saveResult.data가 3단계로 중첩된 경우 실제 폼 데이터 추출
|
||||||
|
// saveResult.data = API 응답 { success, data, message }
|
||||||
|
// saveResult.data.data = 저장된 레코드 { id, screenId, tableName, data, createdAt... }
|
||||||
|
// saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... }
|
||||||
|
const savedRecord = saveResult?.data?.data || saveResult?.data || {};
|
||||||
|
const actualFormData = savedRecord?.data || savedRecord; // ← 3단계까지 추출
|
||||||
|
const formData = (Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수정 핵심
|
||||||
|
1. `savedRecord`: 저장된 레코드 메타데이터 (`{ id, screenId, tableName, data, ... }`)
|
||||||
|
2. `actualFormData`: `savedRecord.data`가 있으면 그것을 사용, 없으면 `savedRecord` 자체 사용
|
||||||
|
3. 폴백: `actualFormData`가 비어있으면 `context.formData` 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정된 파일
|
||||||
|
|
||||||
|
| 파일 | 수정 내용 |
|
||||||
|
|------|-----------|
|
||||||
|
| `frontend/lib/utils/buttonActions.ts` | 3단계 중첩 데이터 구조에서 실제 폼 데이터 추출 로직 수정 (라인 1619-1628) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
### 수정 전
|
||||||
|
```
|
||||||
|
📦 [executeAfterSaveControl] savedData 필드: ['id', 'screenId', 'tableName', 'data', ...]
|
||||||
|
📦 [executeAfterSaveControl] savedData.sabun: undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수정 후
|
||||||
|
```
|
||||||
|
📦 [executeAfterSaveControl] savedRecord 구조: ['id', 'screenId', 'tableName', 'data', ...]
|
||||||
|
📦 [executeAfterSaveControl] actualFormData 추출: ['sabun', 'user_id', 'user_password', ...]
|
||||||
|
📦 [executeAfterSaveControl] formData.sabun: 20260205-087
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB 확인
|
||||||
|
```sql
|
||||||
|
SELECT sabun, user_name, user_password FROM user_info WHERE sabun = '20260205-087';
|
||||||
|
-- 결과: sabun: "20260205-087", user_name: "TEST", user_password: "1e538e2abdd9663437343212a4853591"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 교훈
|
||||||
|
|
||||||
|
1. **API 응답 구조 확인**: API 응답이 여러 단계로 래핑될 수 있음. 프론트엔드에서 `apiClient`가 한 번, `ApiResponse` 인터페이스가 한 번, 그리고 실제 데이터 구조가 또 다른 레벨을 가질 수 있음.
|
||||||
|
|
||||||
|
2. **로그 추가의 중요성**: 중간 단계마다 로그를 찍어 데이터 구조를 확인하는 것이 디버깅에 필수적.
|
||||||
|
|
||||||
|
3. **폴백 처리**: 데이터 추출 시 여러 단계의 폴백을 두어 다양한 응답 구조에 대응.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 이슈
|
||||||
|
|
||||||
|
- 비밀번호 자동 설정 노드 플로우가 저장 후 실행되지 않는 문제
|
||||||
|
- 저장 후 연결된 UPDATE 플로우에서 WHERE 조건이 작동하지 않는 문제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작성 정보
|
||||||
|
|
||||||
|
- **작성일**: 2026-02-05
|
||||||
|
- **작성자**: AI Assistant
|
||||||
|
- **관련 화면**: 부서관리 > 사용자 등록 모달
|
||||||
|
- **관련 플로우**: flowId: 120 (부서관리 비밀번호 자동세팅)
|
||||||
|
|
@ -531,26 +531,34 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
return {
|
return {
|
||||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
||||||
|
needsScroll: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + padding
|
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
|
||||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
// 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
|
||||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
|
||||||
const dialogGap = 16; // DialogContent gap-4
|
const footerHeight = 44; // 연속 등록 모드 체크박스 영역
|
||||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이)
|
||||||
|
const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩)
|
||||||
|
const horizontalPadding = 16; // 좌우 패딩 최소화
|
||||||
|
|
||||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + extraPadding;
|
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding;
|
||||||
|
const maxAvailableHeight = window.innerHeight * 0.95;
|
||||||
|
|
||||||
|
// 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요
|
||||||
|
const needsScroll = totalHeight > maxAvailableHeight;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
className: "overflow-hidden p-0",
|
className: "overflow-hidden p-0",
|
||||||
style: {
|
style: {
|
||||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
|
||||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
// 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
|
||||||
|
maxHeight: `${maxAvailableHeight}px`,
|
||||||
maxWidth: "98vw",
|
maxWidth: "98vw",
|
||||||
maxHeight: "95vh",
|
|
||||||
},
|
},
|
||||||
|
needsScroll,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -618,7 +626,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
return (
|
return (
|
||||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
||||||
{...(modalStyle.style && { style: modalStyle.style })}
|
{...(modalStyle.style && { style: modalStyle.style })}
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||||
|
|
@ -633,7 +641,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
<div
|
||||||
|
className={`flex-1 min-h-0 flex items-start justify-center pt-6 ${modalStyle.needsScroll ? 'overflow-auto' : 'overflow-visible'}`}
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -645,11 +655,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: `${screenDimensions?.width || 800}px`,
|
width: `${screenDimensions?.width || 800}px`,
|
||||||
height: `${screenDimensions?.height || 600}px`,
|
height: `${screenDimensions?.height || 600}px`,
|
||||||
transformOrigin: "center center",
|
// 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정
|
||||||
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|
|
||||||
|
|
@ -704,7 +704,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
controlConfig,
|
controlConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
|
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
|
||||||
|
const flowTiming = controlConfig?.dataflowTiming
|
||||||
|
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|
||||||
|
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||||
|
|
||||||
|
if (controlConfig?.enableDataflowControl && flowTiming === "after") {
|
||||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||||
|
|
||||||
// buttonActions의 executeAfterSaveControl 동적 import
|
// buttonActions의 executeAfterSaveControl 동적 import
|
||||||
|
|
@ -865,7 +870,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
|
|
||||||
console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig });
|
console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig });
|
||||||
|
|
||||||
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
|
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
|
||||||
|
const flowTimingInsert = controlConfig?.dataflowTiming
|
||||||
|
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|
||||||
|
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||||
|
|
||||||
|
if (controlConfig?.enableDataflowControl && flowTimingInsert === "after") {
|
||||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||||
|
|
||||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||||
|
|
@ -938,7 +948,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
|
|
||||||
console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig });
|
console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig });
|
||||||
|
|
||||||
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
|
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
|
||||||
|
const flowTimingUpdate = controlConfig?.dataflowTiming
|
||||||
|
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|
||||||
|
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||||
|
|
||||||
|
if (controlConfig?.enableDataflowControl && flowTimingUpdate === "after") {
|
||||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||||
|
|
||||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||||
|
|
|
||||||
|
|
@ -1067,22 +1067,35 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// TableSearchWidget의 경우 높이를 자동으로 설정
|
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||||
|
|
||||||
|
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
|
||||||
|
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
|
||||||
|
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
|
||||||
|
const hasVisibleLabel = isV2InputComponent &&
|
||||||
|
style?.labelDisplay !== false &&
|
||||||
|
(style?.labelText || (component as any).label);
|
||||||
|
|
||||||
|
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
|
||||||
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||||
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
|
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||||
|
|
||||||
const componentStyle = {
|
const componentStyle = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
left: position?.x || 0,
|
left: position?.x || 0,
|
||||||
top: position?.y || 0,
|
top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
|
||||||
zIndex: position?.z || 1,
|
zIndex: position?.z || 1,
|
||||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||||
|
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
|
||||||
|
overflow: labelOffset > 0 ? "visible" : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute" style={componentStyle}>
|
<div className="absolute" style={componentStyle}>
|
||||||
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
|
||||||
{/* 위젯 렌더링 */}
|
|
||||||
{renderInteractiveWidget(component)}
|
{renderInteractiveWidget(component)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,9 @@ const WidgetRenderer: React.FC<{
|
||||||
tableDisplayData?: any[];
|
tableDisplayData?: any[];
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => {
|
}> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => {
|
||||||
|
// 🔧 무조건 로그 (렌더링 확인용)
|
||||||
|
console.log("📦 WidgetRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
|
||||||
|
|
||||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||||
if (!isWidgetComponent(component)) {
|
if (!isWidgetComponent(component)) {
|
||||||
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
||||||
|
|
@ -127,9 +130,6 @@ const WidgetRenderer: React.FC<{
|
||||||
const widget = component;
|
const widget = component;
|
||||||
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
||||||
|
|
||||||
// 디버깅: 실제 widgetType 값 확인
|
|
||||||
// console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
|
|
||||||
|
|
||||||
// 사용자가 테두리를 설정했는지 확인
|
// 사용자가 테두리를 설정했는지 확인
|
||||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||||
|
|
||||||
|
|
@ -246,8 +246,17 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
tableDisplayData, // 🆕 화면 표시 데이터
|
tableDisplayData, // 🆕 화면 표시 데이터
|
||||||
...restProps
|
...restProps
|
||||||
}) => {
|
}) => {
|
||||||
|
// 🔧 무조건 로그 - 파일 반영 테스트용 (2024-TEST)
|
||||||
|
console.log("🔷🔷🔷 RealtimePreview 2024:", component.id);
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { type, id, position, size, style = {} } = component;
|
const { type, id, position, size, style = {} } = component;
|
||||||
|
|
||||||
|
// 🔧 v2 컴포넌트 렌더링 추적
|
||||||
|
if (id?.includes("v2-")) {
|
||||||
|
console.log("🔷 RealtimePreview 렌더:", id, "type:", type, "labelDisplay:", style?.labelDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
|
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
|
||||||
const [actualHeight, setActualHeight] = useState<number | null>(null);
|
const [actualHeight, setActualHeight] = useState<number | null>(null);
|
||||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -741,6 +750,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
||||||
{type === "component" &&
|
{type === "component" &&
|
||||||
(() => {
|
(() => {
|
||||||
|
console.log("📦 DynamicComponentRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
|
||||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||||
return (
|
return (
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
|
|
|
||||||
|
|
@ -598,12 +598,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
(contentRef as any).current = node;
|
(contentRef as any).current = node;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`${
|
className="h-full overflow-visible"
|
||||||
(component.type === "component" && (component as any).componentType === "flow-widget") ||
|
|
||||||
((component as any).componentType === "conditional-container" && !isDesignMode)
|
|
||||||
? "h-auto"
|
|
||||||
: "h-full"
|
|
||||||
} overflow-visible`}
|
|
||||||
style={{ width: "100%", maxWidth: "100%" }}
|
style={{ width: "100%", maxWidth: "100%" }}
|
||||||
>
|
>
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
|
|
@ -649,9 +644,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택된 컴포넌트 정보 표시 */}
|
{/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="bg-primary text-primary-foreground absolute -top-7 left-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
|
<div className="bg-primary text-primary-foreground absolute -top-7 right-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
|
||||||
{type === "widget" && (
|
{type === "widget" && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
||||||
|
|
@ -690,7 +685,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// React.memo로 래핑하여 불필요한 리렌더링 방지
|
// 🔧 arePropsEqual 제거 - 기본 React.memo 사용 (디버깅용)
|
||||||
|
// component 객체가 새로 생성되면 자동으로 리렌더링됨
|
||||||
export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent);
|
export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent);
|
||||||
|
|
||||||
// displayName 설정 (디버깅용)
|
// displayName 설정 (디버깅용)
|
||||||
|
|
|
||||||
|
|
@ -472,14 +472,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
// 이미 배치된 컬럼 목록 계산
|
// 이미 배치된 컬럼 목록 계산
|
||||||
const placedColumns = useMemo(() => {
|
const placedColumns = useMemo(() => {
|
||||||
const placed = new Set<string>();
|
const placed = new Set<string>();
|
||||||
|
// 🔧 화면의 메인 테이블명을 fallback으로 사용
|
||||||
|
const screenTableName = selectedScreen?.tableName;
|
||||||
|
|
||||||
const collectColumns = (components: ComponentData[]) => {
|
const collectColumns = (components: ComponentData[]) => {
|
||||||
components.forEach((comp) => {
|
components.forEach((comp) => {
|
||||||
const anyComp = comp as any;
|
const anyComp = comp as any;
|
||||||
|
|
||||||
// widget 타입 또는 component 타입 (새로운 시스템)에서 tableName과 columnName 확인
|
// 🔧 tableName과 columnName을 여러 위치에서 찾기 (최상위, componentConfig, 또는 화면 테이블명)
|
||||||
if ((comp.type === "widget" || comp.type === "component") && anyComp.tableName && anyComp.columnName) {
|
const tableName = anyComp.tableName || anyComp.componentConfig?.tableName || screenTableName;
|
||||||
const key = `${anyComp.tableName}.${anyComp.columnName}`;
|
const columnName = anyComp.columnName || anyComp.componentConfig?.columnName;
|
||||||
|
|
||||||
|
// widget 타입 또는 component 타입에서 columnName 확인 (tableName은 화면 테이블명으로 fallback)
|
||||||
|
if ((comp.type === "widget" || comp.type === "component") && tableName && columnName) {
|
||||||
|
const key = `${tableName}.${columnName}`;
|
||||||
placed.add(key);
|
placed.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -492,7 +498,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
collectColumns(layout.components);
|
collectColumns(layout.components);
|
||||||
return placed;
|
return placed;
|
||||||
}, [layout.components]);
|
}, [layout.components, selectedScreen?.tableName]);
|
||||||
|
|
||||||
// 히스토리에 저장
|
// 히스토리에 저장
|
||||||
const saveToHistory = useCallback(
|
const saveToHistory = useCallback(
|
||||||
|
|
@ -770,6 +776,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
const finalKey = pathParts[pathParts.length - 1];
|
const finalKey = pathParts[pathParts.length - 1];
|
||||||
current[finalKey] = value;
|
current[finalKey] = value;
|
||||||
|
|
||||||
|
// 🔧 style 관련 업데이트 디버그 로그
|
||||||
|
if (path.includes("style") || path.includes("labelDisplay")) {
|
||||||
|
console.log("🎨 style 업데이트 제대로 렌더링된거다 내가바꿈:", {
|
||||||
|
componentId: comp.id,
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
updatedStyle: newComp.style,
|
||||||
|
pathIncludesLabelDisplay: path.includes("labelDisplay"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 labelDisplay 변경 시 강제 리렌더링 트리거 (조건문 밖으로 이동)
|
||||||
|
if (path === "style.labelDisplay") {
|
||||||
|
console.log("⏰⏰⏰ labelDisplay 변경 감지! forceRenderTrigger 실행 예정");
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
|
// 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
|
||||||
if (path === "size.width" || path === "size.height" || path === "size") {
|
if (path === "size.width" || path === "size.height" || path === "size") {
|
||||||
// 🔧 style 객체를 새로 복사하여 불변성 유지
|
// 🔧 style 객체를 새로 복사하여 불변성 유지
|
||||||
|
|
@ -1787,97 +1809,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
const buttonComponents = layoutWithResolution.components.filter(
|
const buttonComponents = layoutWithResolution.components.filter(
|
||||||
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
||||||
);
|
);
|
||||||
console.log("💾 저장 시작:", {
|
// 💾 저장 로그 (디버그 완료 - 간소화)
|
||||||
screenId: selectedScreen.screenId,
|
// console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length });
|
||||||
componentsCount: layoutWithResolution.components.length,
|
// 분할 패널 디버그 로그 (주석 처리)
|
||||||
gridSettings: layoutWithResolution.gridSettings,
|
|
||||||
screenResolution: layoutWithResolution.screenResolution,
|
|
||||||
buttonComponents: buttonComponents.map((c: any) => ({
|
|
||||||
id: c.id,
|
|
||||||
type: c.type,
|
|
||||||
componentType: c.componentType,
|
|
||||||
text: c.componentConfig?.text,
|
|
||||||
actionType: c.componentConfig?.action?.type,
|
|
||||||
fullAction: c.componentConfig?.action,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🔍 디버그: 분할 패널 내부의 탭 및 컴포넌트 설정 확인
|
|
||||||
const splitPanels = layoutWithResolution.components.filter(
|
|
||||||
(c: any) => c.componentType === "v2-split-panel-layout" || c.componentType === "split-panel-layout"
|
|
||||||
);
|
|
||||||
splitPanels.forEach((sp: any) => {
|
|
||||||
console.log("🔍 [저장] 분할 패널 설정:", {
|
|
||||||
id: sp.id,
|
|
||||||
leftPanel: sp.componentConfig?.leftPanel,
|
|
||||||
rightPanel: sp.componentConfig?.rightPanel,
|
|
||||||
});
|
|
||||||
// 🆕 분할 패널 내 모든 컴포넌트의 componentConfig 로그
|
|
||||||
const rightComponents = sp.componentConfig?.rightPanel?.components || [];
|
|
||||||
console.log("🔍 [저장] 오른쪽 패널 컴포넌트들:", rightComponents.map((c: any) => ({
|
|
||||||
id: c.id,
|
|
||||||
componentType: c.componentType,
|
|
||||||
hasComponentConfig: !!c.componentConfig,
|
|
||||||
componentConfig: JSON.parse(JSON.stringify(c.componentConfig || {})),
|
|
||||||
})));
|
|
||||||
// 왼쪽 패널의 탭 컴포넌트 확인
|
|
||||||
const leftTabs = sp.componentConfig?.leftPanel?.components?.filter(
|
|
||||||
(c: any) => c.componentType === "v2-tabs-widget"
|
|
||||||
);
|
|
||||||
leftTabs?.forEach((tabWidget: any) => {
|
|
||||||
console.log("🔍 [저장] 왼쪽 패널 탭 위젯 전체 componentConfig:", {
|
|
||||||
tabWidgetId: tabWidget.id,
|
|
||||||
fullComponentConfig: JSON.parse(JSON.stringify(tabWidget.componentConfig || {})),
|
|
||||||
});
|
|
||||||
console.log("🔍 [저장] 왼쪽 패널 탭 내부 컴포넌트:", {
|
|
||||||
tabId: tabWidget.id,
|
|
||||||
tabs: tabWidget.componentConfig?.tabs?.map((t: any) => ({
|
|
||||||
id: t.id,
|
|
||||||
label: t.label,
|
|
||||||
componentsCount: t.components?.length || 0,
|
|
||||||
components: t.components,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// 오른쪽 패널의 탭 컴포넌트 확인
|
|
||||||
const rightTabs = sp.componentConfig?.rightPanel?.components?.filter(
|
|
||||||
(c: any) => c.componentType === "v2-tabs-widget"
|
|
||||||
);
|
|
||||||
rightTabs?.forEach((tabWidget: any) => {
|
|
||||||
console.log("🔍 [저장] 오른쪽 패널 탭 위젯 전체 componentConfig:", {
|
|
||||||
tabWidgetId: tabWidget.id,
|
|
||||||
fullComponentConfig: JSON.parse(JSON.stringify(tabWidget.componentConfig || {})),
|
|
||||||
});
|
|
||||||
console.log("🔍 [저장] 오른쪽 패널 탭 내부 컴포넌트:", {
|
|
||||||
tabId: tabWidget.id,
|
|
||||||
tabs: tabWidget.componentConfig?.tabs?.map((t: any) => ({
|
|
||||||
id: t.id,
|
|
||||||
label: t.label,
|
|
||||||
componentsCount: t.components?.length || 0,
|
|
||||||
components: t.components,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// V2 API 사용 여부에 따라 분기
|
// V2 API 사용 여부에 따라 분기
|
||||||
if (USE_V2_API) {
|
if (USE_V2_API) {
|
||||||
|
// 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
|
||||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||||
console.log("📦 V2 변환 결과 (분할 패널 overrides):", v2Layout.components
|
|
||||||
.filter((c: any) => c.url?.includes("split-panel"))
|
|
||||||
.map((c: any) => ({
|
|
||||||
id: c.id,
|
|
||||||
url: c.url,
|
|
||||||
overrides: c.overrides,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||||
console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 저장 성공! 메뉴 할당 모달 열기");
|
// console.log("✅ 저장 성공!");
|
||||||
toast.success("화면이 저장되었습니다.");
|
toast.success("화면이 저장되었습니다.");
|
||||||
|
|
||||||
// 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영)
|
// 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영)
|
||||||
|
|
@ -3084,7 +3030,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
},
|
},
|
||||||
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
labelDisplay: true, // 🆕 라벨 기본 표시 (사용자가 끄고 싶으면 체크 해제)
|
||||||
labelFontSize: "14px",
|
labelFontSize: "14px",
|
||||||
labelColor: "#212121",
|
labelColor: "#212121",
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
|
|
@ -3750,7 +3696,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
entityJoinColumn: column.entityJoinColumn,
|
entityJoinColumn: column.entityJoinColumn,
|
||||||
}),
|
}),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: false, // 라벨 숨김
|
labelDisplay: true, // 🆕 라벨 기본 표시
|
||||||
labelFontSize: "12px",
|
labelFontSize: "12px",
|
||||||
labelColor: "#212121",
|
labelColor: "#212121",
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
|
|
@ -3816,7 +3762,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
entityJoinColumn: column.entityJoinColumn,
|
entityJoinColumn: column.entityJoinColumn,
|
||||||
}),
|
}),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: false, // 라벨 숨김
|
labelDisplay: true, // 🆕 라벨 기본 표시
|
||||||
labelFontSize: "14px",
|
labelFontSize: "14px",
|
||||||
labelColor: "#000000", // 순수한 검정
|
labelColor: "#000000", // 순수한 검정
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
|
|
@ -5452,6 +5398,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 ScreenDesigner 렌더링 확인 (디버그 완료 - 주석 처리)
|
||||||
|
// console.log("🏠 ScreenDesigner 렌더!", Date.now());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
|
|
@ -6163,6 +6112,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
// 그룹에 속하지 않은 일반 컴포넌트들
|
// 그룹에 속하지 않은 일반 컴포넌트들
|
||||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||||||
|
|
||||||
|
// 🔧 렌더링 확인 로그 (디버그 완료 - 주석 처리)
|
||||||
|
// console.log("🔄 ScreenDesigner 렌더링:", { componentsCount: regularComponents.length, forceRenderTrigger, timestamp: Date.now() });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 일반 컴포넌트들 */}
|
{/* 일반 컴포넌트들 */}
|
||||||
|
|
@ -6228,11 +6180,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
const globalFiles = globalFileState[component.id] || [];
|
const globalFiles = globalFileState[component.id] || [];
|
||||||
const componentFiles = (component as any).uploadedFiles || [];
|
const componentFiles = (component as any).uploadedFiles || [];
|
||||||
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
|
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
|
||||||
|
// 🆕 style 변경 시 리렌더링을 위한 key 추가
|
||||||
|
const styleKey = component.style?.labelDisplay !== undefined ? `label-${component.style.labelDisplay}` : "";
|
||||||
|
const fullKey = `${component.id}-${fileStateKey}-${styleKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`;
|
||||||
|
|
||||||
|
// 🔧 v2-input 계열 컴포넌트 key 변경 로그 (디버그 완료 - 주석 처리)
|
||||||
|
// if (component.id.includes("v2-") || component.widgetType?.includes("v2-")) { console.log("🔑 RealtimePreview key:", { id: component.id, styleKey, labelDisplay: component.style?.labelDisplay, forceRenderTrigger, fullKey }); }
|
||||||
|
|
||||||
|
// 🆕 labelDisplay 변경 시 새 객체로 강제 변경 감지
|
||||||
|
const componentWithLabel = {
|
||||||
|
...displayComponent,
|
||||||
|
_labelDisplayKey: component.style?.labelDisplay,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RealtimePreview
|
<RealtimePreview
|
||||||
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`}
|
key={fullKey}
|
||||||
component={displayComponent}
|
component={componentWithLabel}
|
||||||
isSelected={
|
isSelected={
|
||||||
selectedComponent?.id === component.id ||
|
selectedComponent?.id === component.id ||
|
||||||
groupState.selectedComponents.includes(component.id)
|
groupState.selectedComponents.includes(component.id)
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,8 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
||||||
|
|
||||||
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
||||||
...dataflowConfig,
|
...dataflowConfig,
|
||||||
|
// 🔧 controlMode 설정 (플로우 제어가 있으면 "flow", 없으면 "none")
|
||||||
|
controlMode: firstValidControl ? "flow" : "none",
|
||||||
// 기존 형식 (하위 호환성)
|
// 기존 형식 (하위 호환성)
|
||||||
selectedDiagramId: firstValidControl?.flowId || null,
|
selectedDiagramId: firstValidControl?.flowId || null,
|
||||||
selectedRelationshipId: null,
|
selectedRelationshipId: null,
|
||||||
|
|
|
||||||
|
|
@ -823,7 +823,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">라벨 텍스트</Label>
|
<Label className="text-xs">라벨 텍스트</Label>
|
||||||
<Input
|
<Input
|
||||||
value={selectedComponent.style?.labelText !== undefined ? selectedComponent.style.labelText : (selectedComponent.label || "")}
|
value={selectedComponent.style?.labelText !== undefined ? selectedComponent.style.labelText : (selectedComponent.label || selectedComponent.componentConfig?.label || "")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleUpdate("style.labelText", e.target.value);
|
handleUpdate("style.labelText", e.target.value);
|
||||||
handleUpdate("label", e.target.value); // label도 함께 업데이트
|
handleUpdate("label", e.target.value); // label도 함께 업데이트
|
||||||
|
|
@ -862,8 +862,23 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2 pt-5">
|
<div className="flex items-center space-x-2 pt-5">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedComponent.style?.labelDisplay !== false}
|
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
|
||||||
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
|
onCheckedChange={(checked) => {
|
||||||
|
const boolValue = checked === true;
|
||||||
|
// 🔧 "필수"처럼 직접 경로로 업데이트! (style 객체 전체 덮어쓰기 방지)
|
||||||
|
handleUpdate("style.labelDisplay", boolValue);
|
||||||
|
handleUpdate("labelDisplay", boolValue);
|
||||||
|
// labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음)
|
||||||
|
if (boolValue && !selectedComponent.style?.labelText) {
|
||||||
|
const labelValue =
|
||||||
|
selectedComponent.label ||
|
||||||
|
selectedComponent.componentConfig?.label ||
|
||||||
|
"";
|
||||||
|
if (labelValue) {
|
||||||
|
handleUpdate("style.labelText", labelValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<Label className="text-xs">표시</Label>
|
<Label className="text-xs">표시</Label>
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,25 @@ function SelectTrigger({
|
||||||
className,
|
className,
|
||||||
size = "xs",
|
size = "xs",
|
||||||
children,
|
children,
|
||||||
|
style,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "xs" | "sm" | "default";
|
size?: "xs" | "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
|
// className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시
|
||||||
|
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={hasCustomHeight ? undefined : size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
// 커스텀 높이일 때 기본 패딩 적용
|
||||||
|
hasCustomHeight && "px-2 py-1",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={style}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -311,36 +311,39 @@ export const V2Biz = forwardRef<HTMLDivElement, V2BizProps>(
|
||||||
const componentWidth = size?.width || style?.width;
|
const componentWidth = size?.width || style?.width;
|
||||||
const componentHeight = size?.height || style?.height;
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||||
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||||
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className="flex flex-col"
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
width: componentWidth,
|
width: componentWidth,
|
||||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
height: componentHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
style={{
|
style={{
|
||||||
fontSize: style?.labelFontSize,
|
position: "absolute",
|
||||||
color: style?.labelColor,
|
top: `-${estimatedLabelHeight}px`,
|
||||||
fontWeight: style?.labelFontWeight,
|
left: 0,
|
||||||
marginBottom: style?.labelMarginBottom,
|
fontSize: style?.labelFontSize || "14px",
|
||||||
|
color: style?.labelColor || "#64748b",
|
||||||
|
fontWeight: style?.labelFontWeight || "500",
|
||||||
}}
|
}}
|
||||||
className="text-sm font-medium flex-shrink-0"
|
className="text-sm font-medium whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="h-full w-full">
|
||||||
className="min-h-0"
|
|
||||||
style={{
|
|
||||||
height: componentHeight,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderBiz()}
|
{renderBiz()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,11 @@ const SingleDatePicker = forwardRef<
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{ value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className },
|
{ value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className, placeholder = "날짜 선택" },
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -87,6 +88,16 @@ const SingleDatePicker = forwardRef<
|
||||||
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
|
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
|
||||||
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
|
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
|
||||||
|
|
||||||
|
// 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로)
|
||||||
|
const displayText = useMemo(() => {
|
||||||
|
if (!value) return "";
|
||||||
|
// Date 객체로 변환 후 포맷팅
|
||||||
|
if (date && isValid(date)) {
|
||||||
|
return formatDate(date, dateFormat);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}, [value, date, dateFormat]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(selectedDate: Date | undefined) => {
|
(selectedDate: Date | undefined) => {
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
|
|
@ -115,13 +126,13 @@ const SingleDatePicker = forwardRef<
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={disabled || readonly}
|
disabled={disabled || readonly}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-full justify-start text-left font-normal",
|
"h-full w-full justify-start text-left font-normal",
|
||||||
!value && "text-muted-foreground",
|
!displayText && "text-muted-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||||
{value || "날짜 선택"}
|
{displayText || placeholder}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
|
@ -211,14 +222,14 @@ const RangeDatePicker = forwardRef<
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("flex items-center gap-2", className)}>
|
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
|
||||||
{/* 시작 날짜 */}
|
{/* 시작 날짜 */}
|
||||||
<Popover open={openStart} onOpenChange={setOpenStart}>
|
<Popover open={openStart} onOpenChange={setOpenStart}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={disabled || readonly}
|
disabled={disabled || readonly}
|
||||||
className={cn("h-10 flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
|
className={cn("h-full flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{value[0] || "시작일"}
|
{value[0] || "시작일"}
|
||||||
|
|
@ -248,7 +259,7 @@ const RangeDatePicker = forwardRef<
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={disabled || readonly}
|
disabled={disabled || readonly}
|
||||||
className={cn("h-10 flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
|
className={cn("h-full flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{value[1] || "종료일"}
|
{value[1] || "종료일"}
|
||||||
|
|
@ -290,7 +301,7 @@ const TimePicker = forwardRef<
|
||||||
}
|
}
|
||||||
>(({ value, onChange, disabled, readonly, className }, ref) => {
|
>(({ value, onChange, disabled, readonly, className }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative", className)}>
|
<div className={cn("relative h-full", className)}>
|
||||||
<Clock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
<Clock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -299,7 +310,7 @@ const TimePicker = forwardRef<
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly={readonly}
|
readOnly={readonly}
|
||||||
className="h-10 pl-10"
|
className="h-full pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -346,8 +357,8 @@ const DateTimePicker = forwardRef<
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("flex gap-2", className)}>
|
<div ref={ref} className={cn("flex gap-2 h-full", className)}>
|
||||||
<div className="flex-1">
|
<div className="flex-1 h-full">
|
||||||
<SingleDatePicker
|
<SingleDatePicker
|
||||||
value={datePart}
|
value={datePart}
|
||||||
onChange={handleDateChange}
|
onChange={handleDateChange}
|
||||||
|
|
@ -358,7 +369,7 @@ const DateTimePicker = forwardRef<
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-32">
|
<div className="w-1/3 min-w-[100px] h-full">
|
||||||
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
|
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -409,6 +420,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
||||||
maxDate={config.maxDate}
|
maxDate={config.maxDate}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
|
placeholder={config.placeholder}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -444,6 +456,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
||||||
showToday={config.showToday}
|
showToday={config.showToday}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
|
placeholder={config.placeholder}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -453,37 +466,40 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
||||||
const componentWidth = size?.width || style?.width;
|
const componentWidth = size?.width || style?.width;
|
||||||
const componentHeight = size?.height || style?.height;
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||||
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||||
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className="flex flex-col"
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
width: componentWidth,
|
width: componentWidth,
|
||||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
height: componentHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
style={{
|
style={{
|
||||||
fontSize: style?.labelFontSize,
|
position: "absolute",
|
||||||
color: style?.labelColor,
|
top: `-${estimatedLabelHeight}px`,
|
||||||
fontWeight: style?.labelFontWeight,
|
left: 0,
|
||||||
marginBottom: style?.labelMarginBottom,
|
fontSize: style?.labelFontSize || "14px",
|
||||||
|
color: style?.labelColor || "#64748b",
|
||||||
|
fontWeight: style?.labelFontWeight || "500",
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 text-sm font-medium"
|
className="text-sm font-medium whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="h-full w-full">
|
||||||
className="min-h-0"
|
|
||||||
style={{
|
|
||||||
height: componentHeight,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderDatePicker()}
|
{renderDatePicker()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -462,37 +462,40 @@ export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>(
|
||||||
const componentWidth = size?.width || style?.width;
|
const componentWidth = size?.width || style?.width;
|
||||||
const componentHeight = size?.height || style?.height;
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||||
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||||
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className="flex flex-col"
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
width: componentWidth,
|
width: componentWidth,
|
||||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
height: componentHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
style={{
|
style={{
|
||||||
fontSize: style?.labelFontSize,
|
position: "absolute",
|
||||||
color: style?.labelColor,
|
top: `-${estimatedLabelHeight}px`,
|
||||||
fontWeight: style?.labelFontWeight,
|
left: 0,
|
||||||
marginBottom: style?.labelMarginBottom,
|
fontSize: style?.labelFontSize || "14px",
|
||||||
|
color: style?.labelColor || "#64748b",
|
||||||
|
fontWeight: style?.labelFontWeight || "500",
|
||||||
}}
|
}}
|
||||||
className="text-sm font-medium flex-shrink-0"
|
className="text-sm font-medium whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="h-full w-full">
|
||||||
className="min-h-0"
|
|
||||||
style={{
|
|
||||||
height: componentHeight,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderHierarchy()}
|
{renderHierarchy()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,7 @@ const TextareaInput = forwardRef<
|
||||||
readOnly={readonly}
|
readOnly={readonly}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-full w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -848,42 +848,47 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
||||||
const showLabel = label && style?.labelDisplay !== false;
|
// 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
|
||||||
|
const actualLabel = label || style?.labelText;
|
||||||
|
const showLabel = actualLabel && style?.labelDisplay === true;
|
||||||
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
||||||
const componentWidth = size?.width || style?.width;
|
const componentWidth = size?.width || style?.width;
|
||||||
const componentHeight = size?.height || style?.height;
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||||
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||||
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className="flex flex-col"
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
width: componentWidth,
|
width: componentWidth,
|
||||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
height: componentHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
style={{
|
style={{
|
||||||
fontSize: style?.labelFontSize,
|
position: "absolute",
|
||||||
color: style?.labelColor,
|
top: `-${estimatedLabelHeight}px`,
|
||||||
fontWeight: style?.labelFontWeight,
|
left: 0,
|
||||||
marginBottom: style?.labelMarginBottom,
|
fontSize: style?.labelFontSize || "14px",
|
||||||
|
color: style?.labelColor || "#64748b",
|
||||||
|
fontWeight: style?.labelFontWeight || "500",
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 text-sm font-medium"
|
className="text-sm font-medium whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{label}
|
{actualLabel}
|
||||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="h-full w-full">
|
||||||
className="min-h-0"
|
|
||||||
style={{
|
|
||||||
height: componentHeight,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderInput()}
|
{renderInput()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export const V2List = forwardRef<HTMLDivElement, V2ListProps>((props, ref) => {
|
||||||
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
|
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
|
||||||
style={{
|
style={{
|
||||||
width: size?.width || style?.width || "100%",
|
width: size?.width || style?.width || "100%",
|
||||||
height: size?.height || style?.height || 400,
|
height: size?.height || style?.height || "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-muted-foreground text-sm">테이블이 설정되지 않았습니다.</p>
|
<p className="text-muted-foreground text-sm">테이블이 설정되지 않았습니다.</p>
|
||||||
|
|
@ -149,7 +149,7 @@ export const V2List = forwardRef<HTMLDivElement, V2ListProps>((props, ref) => {
|
||||||
className="flex flex-col overflow-auto"
|
className="flex flex-col overflow-auto"
|
||||||
style={{
|
style={{
|
||||||
width: size?.width || style?.width || "100%",
|
width: size?.width || style?.width || "100%",
|
||||||
height: size?.height || style?.height || 400,
|
height: size?.height || style?.height || "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableListComponent
|
<TableListComponent
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}>(({
|
}>(({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
|
|
@ -52,7 +53,8 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
maxSelect,
|
maxSelect,
|
||||||
allowClear = true,
|
allowClear = true,
|
||||||
disabled,
|
disabled,
|
||||||
className
|
className,
|
||||||
|
style,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -64,7 +66,8 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
onValueChange={(v) => onChange?.(v)}
|
onValueChange={(v) => onChange?.(v)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SelectTrigger ref={ref} className={cn("h-10", className)}>
|
{/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */}
|
||||||
|
<SelectTrigger ref={ref} className={cn("w-full", className)} style={style}>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -112,13 +115,15 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
{/* Button에 style로 직접 height 전달 (Popover도 DOM 체인 끊김) */}
|
||||||
<Button
|
<Button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn("h-10 w-full justify-between font-normal", className)}
|
className={cn("w-full justify-between font-normal", className)}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
<span className="truncate flex-1 text-left">
|
<span className="truncate flex-1 text-left">
|
||||||
{selectedLabels.length > 0
|
{selectedLabels.length > 0
|
||||||
|
|
@ -368,9 +373,9 @@ const SwapSelect = forwardRef<HTMLDivElement, {
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("flex gap-2 items-stretch", className)}>
|
<div ref={ref} className={cn("flex gap-2 items-stretch", className)}>
|
||||||
{/* 왼쪽: 선택 가능 */}
|
{/* 왼쪽: 선택 가능 */}
|
||||||
<div className="flex-1 border rounded-md">
|
<div className="flex-1 border rounded-md flex flex-col min-h-0">
|
||||||
<div className="p-2 bg-muted text-xs font-medium border-b">선택 가능</div>
|
<div className="p-2 bg-muted text-xs font-medium border-b shrink-0">선택 가능</div>
|
||||||
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
<div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
|
||||||
{available.map((option) => (
|
{available.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.value}
|
key={option.value}
|
||||||
|
|
@ -412,9 +417,9 @@ const SwapSelect = forwardRef<HTMLDivElement, {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: 선택됨 */}
|
{/* 오른쪽: 선택됨 */}
|
||||||
<div className="flex-1 border rounded-md">
|
<div className="flex-1 border rounded-md flex flex-col min-h-0">
|
||||||
<div className="p-2 bg-primary/10 text-xs font-medium border-b">선택됨</div>
|
<div className="p-2 bg-primary/10 text-xs font-medium border-b shrink-0">선택됨</div>
|
||||||
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
<div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
|
||||||
{selected.map((option) => (
|
{selected.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.value}
|
key={option.value}
|
||||||
|
|
@ -654,24 +659,31 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
// 모드별 컴포넌트 렌더링
|
// 모드별 컴포넌트 렌더링
|
||||||
const renderSelect = () => {
|
const renderSelect = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="h-10 flex items-center text-sm text-muted-foreground">로딩 중...</div>;
|
return <div className="h-full flex items-center text-sm text-muted-foreground">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDisabled = disabled || readonly;
|
const isDisabled = disabled || readonly;
|
||||||
|
|
||||||
|
// 명시적 높이가 있을 때만 style 전달, 없으면 undefined (기본 높이 h-6 사용)
|
||||||
|
const heightStyle: React.CSSProperties | undefined = componentHeight
|
||||||
|
? { height: componentHeight }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
switch (config.mode) {
|
switch (config.mode) {
|
||||||
case "dropdown":
|
case "dropdown":
|
||||||
|
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
|
||||||
return (
|
return (
|
||||||
<DropdownSelect
|
<DropdownSelect
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder="선택"
|
placeholder="선택"
|
||||||
searchable={config.searchable}
|
searchable={config.mode === "combobox" ? true : config.searchable}
|
||||||
multiple={config.multiple}
|
multiple={config.multiple}
|
||||||
maxSelect={config.maxSelect}
|
maxSelect={config.maxSelect}
|
||||||
allowClear={config.allowClear}
|
allowClear={config.allowClear}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
style={heightStyle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -686,6 +698,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
);
|
);
|
||||||
|
|
||||||
case "check":
|
case "check":
|
||||||
|
case "checkbox": // 🔧 기존 저장된 값 호환
|
||||||
return (
|
return (
|
||||||
<CheckSelect
|
<CheckSelect
|
||||||
options={options}
|
options={options}
|
||||||
|
|
@ -735,6 +748,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
style={heightStyle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -744,37 +758,50 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
const componentWidth = size?.width || style?.width;
|
const componentWidth = size?.width || style?.width;
|
||||||
const componentHeight = size?.height || style?.height;
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
|
// 🔍 디버깅: 높이값 확인 (warn으로 변경하여 캡처되도록)
|
||||||
|
console.warn("🔍 [V2Select] 높이 디버깅:", {
|
||||||
|
id,
|
||||||
|
"size?.height": size?.height,
|
||||||
|
"style?.height": style?.height,
|
||||||
|
componentHeight,
|
||||||
|
size,
|
||||||
|
style,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||||
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||||
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className="flex flex-col"
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
width: componentWidth,
|
width: componentWidth,
|
||||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
height: componentHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
style={{
|
style={{
|
||||||
fontSize: style?.labelFontSize,
|
position: "absolute",
|
||||||
color: style?.labelColor,
|
top: `-${estimatedLabelHeight}px`,
|
||||||
fontWeight: style?.labelFontWeight,
|
left: 0,
|
||||||
marginBottom: style?.labelMarginBottom,
|
fontSize: style?.labelFontSize || "14px",
|
||||||
|
color: style?.labelColor || "#64748b",
|
||||||
|
fontWeight: style?.labelFontWeight || "500",
|
||||||
}}
|
}}
|
||||||
className="text-sm font-medium flex-shrink-0"
|
className="text-sm font-medium whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="h-full w-full">
|
||||||
className="min-h-0"
|
|
||||||
style={{
|
|
||||||
height: componentHeight,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderSelect()}
|
{renderSelect()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,20 @@ export const V2DateConfigPanel: React.FC<V2DateConfigPanelProps> = ({
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* 플레이스홀더 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">플레이스홀더</Label>
|
||||||
|
<Input
|
||||||
|
value={config.placeholder || ""}
|
||||||
|
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||||
|
placeholder="날짜 선택"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-[10px]">날짜가 선택되지 않았을 때 표시할 텍스트</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* 표시 형식 */}
|
{/* 표시 형식 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium">표시 형식</Label>
|
<Label className="text-xs font-medium">표시 형식</Label>
|
||||||
|
|
|
||||||
|
|
@ -299,14 +299,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
const columnName = (component as any).columnName;
|
const columnName = (component as any).columnName;
|
||||||
|
|
||||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||||
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원)
|
// ⚠️ 단, componentType이 "select-basic" 또는 "v2-select"인 경우는 ComponentRegistry로 처리
|
||||||
|
// (다중선택, 체크박스, 라디오 등 고급 모드 지원)
|
||||||
if (
|
if (
|
||||||
(inputType === "category" || webType === "category") &&
|
(inputType === "category" || webType === "category") &&
|
||||||
tableName &&
|
tableName &&
|
||||||
columnName &&
|
columnName &&
|
||||||
componentType === "select-basic"
|
(componentType === "select-basic" || componentType === "v2-select")
|
||||||
) {
|
) {
|
||||||
// select-basic은 ComponentRegistry에서 처리하도록 아래로 통과
|
// select-basic, v2-select는 ComponentRegistry에서 처리하도록 아래로 통과
|
||||||
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||||
try {
|
try {
|
||||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
|
|
@ -323,6 +324,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled;
|
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled;
|
||||||
const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly;
|
const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly;
|
||||||
|
|
||||||
|
// 🔧 높이 계산: component.size에서 height 추출
|
||||||
|
const categorySize = (component as any).size;
|
||||||
|
const categoryStyle = (component as any).style;
|
||||||
|
const categoryLabel = (component as any).label;
|
||||||
|
const categoryId = component.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CategorySelectComponent
|
<CategorySelectComponent
|
||||||
tableName={tableName}
|
tableName={tableName}
|
||||||
|
|
@ -334,6 +341,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
disabled={isFieldDisabled}
|
disabled={isFieldDisabled}
|
||||||
readonly={isFieldReadonly}
|
readonly={isFieldReadonly}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
size={categorySize}
|
||||||
|
style={categoryStyle}
|
||||||
|
label={categoryLabel}
|
||||||
|
id={categoryId}
|
||||||
|
isDesignMode={props.isDesignMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -514,6 +526,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
componentType === "modal-repeater-table" ||
|
componentType === "modal-repeater-table" ||
|
||||||
componentType === "v2-input";
|
componentType === "v2-input";
|
||||||
|
|
||||||
|
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시)
|
||||||
|
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
||||||
|
const effectiveLabel = labelDisplay === true
|
||||||
|
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
|
||||||
|
const mergedStyle = {
|
||||||
|
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
|
||||||
|
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
|
||||||
|
width: finalStyle.width,
|
||||||
|
height: finalStyle.height,
|
||||||
|
};
|
||||||
|
|
||||||
const rendererProps = {
|
const rendererProps = {
|
||||||
component,
|
component,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
|
@ -522,11 +548,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
size: component.size || newComponent.defaultSize,
|
size: component.size || newComponent.defaultSize,
|
||||||
position: component.position,
|
position: component.position,
|
||||||
style: finalStyle, // size를 포함한 최종 style
|
|
||||||
config: component.componentConfig,
|
config: component.componentConfig,
|
||||||
componentConfig: component.componentConfig,
|
componentConfig: component.componentConfig,
|
||||||
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
||||||
...(component.componentConfig || {}),
|
...(component.componentConfig || {}),
|
||||||
|
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
||||||
|
style: mergedStyle,
|
||||||
|
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
|
||||||
|
label: effectiveLabel,
|
||||||
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
|
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
|
||||||
inputType: (component as any).inputType || component.componentConfig?.inputType,
|
inputType: (component as any).inputType || component.componentConfig?.inputType,
|
||||||
columnName: (component as any).columnName || component.componentConfig?.columnName,
|
columnName: (component as any).columnName || component.componentConfig?.columnName,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
@ -23,6 +24,20 @@ interface CategorySelectComponentProps {
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
columnName?: string;
|
columnName?: string;
|
||||||
|
// 🔧 높이 조절을 위한 props 추가
|
||||||
|
style?: React.CSSProperties & {
|
||||||
|
labelDisplay?: boolean;
|
||||||
|
labelFontSize?: string | number;
|
||||||
|
labelColor?: string;
|
||||||
|
labelFontWeight?: string | number;
|
||||||
|
labelMarginBottom?: string | number;
|
||||||
|
};
|
||||||
|
size?: { width?: number | string; height?: number | string };
|
||||||
|
// 🔧 라벨 표시를 위한 props 추가
|
||||||
|
label?: string;
|
||||||
|
id?: string;
|
||||||
|
// 🔧 디자인 모드 처리
|
||||||
|
isDesignMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -43,7 +58,27 @@ export const CategorySelectComponent: React.FC<
|
||||||
readonly = false,
|
readonly = false,
|
||||||
tableName: propTableName,
|
tableName: propTableName,
|
||||||
columnName: propColumnName,
|
columnName: propColumnName,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
label: propLabel,
|
||||||
|
id: propId,
|
||||||
|
isDesignMode = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
// 🔧 높이 계산: size.height > style.height > 기본값(40px)
|
||||||
|
const componentHeight = size?.height || style?.height;
|
||||||
|
const heightStyle: React.CSSProperties = componentHeight
|
||||||
|
? { height: componentHeight }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// 🔧 라벨 관련 계산
|
||||||
|
const label = propLabel || component?.label;
|
||||||
|
const id = propId || component?.id;
|
||||||
|
const showLabel = label && style?.labelDisplay !== false;
|
||||||
|
|
||||||
|
// 라벨 높이 계산 (기본 20px)
|
||||||
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||||
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||||
const [categoryValues, setCategoryValues] = useState<TableCategoryValue[]>(
|
const [categoryValues, setCategoryValues] = useState<TableCategoryValue[]>(
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
@ -97,12 +132,49 @@ export const CategorySelectComponent: React.FC<
|
||||||
onChange?.(newValue);
|
onChange?.(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔧 공통 라벨 렌더링 함수
|
||||||
|
const renderLabel = () => {
|
||||||
|
if (!showLabel) return null;
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: `-${estimatedLabelHeight}px`,
|
||||||
|
left: 0,
|
||||||
|
fontSize: style?.labelFontSize || "14px",
|
||||||
|
color: style?.labelColor || "#64748b",
|
||||||
|
fontWeight: style?.labelFontWeight || "500",
|
||||||
|
}}
|
||||||
|
className="text-sm font-medium whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔧 공통 wrapper 스타일
|
||||||
|
const wrapperStyle: React.CSSProperties = {
|
||||||
|
width: size?.width || style?.width,
|
||||||
|
height: componentHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔧 디자인 모드일 때 클릭 방지
|
||||||
|
const designModeClass = isDesignMode ? "pointer-events-none" : "";
|
||||||
|
|
||||||
// 로딩 중
|
// 로딩 중
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-10 w-full items-center justify-center rounded-md border bg-background px-3 text-sm text-muted-foreground">
|
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
{renderLabel()}
|
||||||
로딩 중...
|
<div
|
||||||
|
className="flex h-full w-full items-center justify-center rounded-md border bg-background px-3 text-sm text-muted-foreground"
|
||||||
|
style={heightStyle}
|
||||||
|
>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -110,8 +182,14 @@ export const CategorySelectComponent: React.FC<
|
||||||
// 에러
|
// 에러
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-10 w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 text-sm text-destructive">
|
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||||
⚠️ {error}
|
{renderLabel()}
|
||||||
|
<div
|
||||||
|
className="flex h-full w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 text-sm text-destructive"
|
||||||
|
style={heightStyle}
|
||||||
|
>
|
||||||
|
⚠️ {error}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -119,33 +197,44 @@ export const CategorySelectComponent: React.FC<
|
||||||
// 카테고리 값이 없음
|
// 카테고리 값이 없음
|
||||||
if (categoryValues.length === 0) {
|
if (categoryValues.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-10 w-full items-center rounded-md border bg-muted px-3 text-sm text-muted-foreground">
|
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||||
설정된 카테고리 값이 없습니다
|
{renderLabel()}
|
||||||
|
<div
|
||||||
|
className="flex h-full w-full items-center rounded-md border bg-muted px-3 text-sm text-muted-foreground"
|
||||||
|
style={heightStyle}
|
||||||
|
>
|
||||||
|
설정된 카테고리 값이 없습니다
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||||
value={value}
|
{renderLabel()}
|
||||||
onValueChange={handleValueChange}
|
<div className="h-full w-full">
|
||||||
disabled={disabled || readonly}
|
<Select
|
||||||
required={required}
|
value={value}
|
||||||
>
|
onValueChange={handleValueChange}
|
||||||
<SelectTrigger className={`w-full ${className}`}>
|
disabled={disabled || readonly}
|
||||||
<SelectValue placeholder={placeholder} />
|
required={required}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<SelectTrigger className={`w-full h-full ${className}`} style={heightStyle}>
|
||||||
{categoryValues.map((categoryValue) => (
|
<SelectValue placeholder={placeholder} />
|
||||||
<SelectItem
|
</SelectTrigger>
|
||||||
key={categoryValue.valueId}
|
<SelectContent>
|
||||||
value={categoryValue.valueCode}
|
{categoryValues.map((categoryValue) => (
|
||||||
>
|
<SelectItem
|
||||||
{categoryValue.valueLabel}
|
key={categoryValue.valueId}
|
||||||
</SelectItem>
|
value={categoryValue.valueCode}
|
||||||
))}
|
>
|
||||||
</SelectContent>
|
{categoryValue.valueLabel}
|
||||||
</Select>
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -555,13 +555,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 스타일 계산
|
// 스타일 계산
|
||||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
// 🔧 사용자가 설정한 크기가 있으면 그대로 사용
|
||||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||||
|
|
@ -1289,19 +1286,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
|
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
|
||||||
|
|
||||||
// 공통 버튼 스타일
|
// 공통 버튼 스타일
|
||||||
// 🔧 component.style에서 background/backgroundColor 충돌 방지
|
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
|
||||||
const userStyle = component.style
|
const userStyle = component.style
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
Object.entries(component.style).filter(
|
Object.entries(component.style).filter(
|
||||||
([key]) => !["width", "height", "background", "backgroundColor"].includes(key),
|
([key]) => !["background", "backgroundColor"].includes(key),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
// 🔧 사용자가 설정한 크기 우선 사용, 없으면 100%
|
||||||
|
const buttonWidth = component.size?.width ? `${component.size.width}px` : (style?.width || "100%");
|
||||||
|
const buttonHeight = component.size?.height ? `${component.size.height}px` : (style?.height || "100%");
|
||||||
|
|
||||||
const buttonElementStyle: React.CSSProperties = {
|
const buttonElementStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: buttonWidth,
|
||||||
height: "100%",
|
height: buttonHeight,
|
||||||
minHeight: "40px",
|
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
|
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,15 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 라벨: style.labelText 우선, 없으면 component.label 사용
|
||||||
|
// style.labelDisplay가 false면 라벨 숨김
|
||||||
|
const style = component.style || {};
|
||||||
|
const effectiveLabel = style.labelDisplay === false ? undefined : (style.labelText || component.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<V2Date
|
<V2Date
|
||||||
id={component.id}
|
id={component.id}
|
||||||
label={component.label}
|
label={effectiveLabel}
|
||||||
required={component.required}
|
required={component.required}
|
||||||
readonly={config.readonly || component.readonly}
|
readonly={config.readonly || component.readonly}
|
||||||
disabled={config.disabled || component.disabled}
|
disabled={config.disabled || component.disabled}
|
||||||
|
|
@ -41,7 +46,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
|
||||||
config={{
|
config={{
|
||||||
dateType: config.dateType || config.webType || "date",
|
dateType: config.dateType || config.webType || "date",
|
||||||
format: config.format || "YYYY-MM-DD",
|
format: config.format || "YYYY-MM-DD",
|
||||||
placeholder: config.placeholder || "날짜 선택",
|
placeholder: config.placeholder || style.placeholder || "날짜 선택",
|
||||||
showTime: config.showTime || false,
|
showTime: config.showTime || false,
|
||||||
use24Hours: config.use24Hours ?? true,
|
use24Hours: config.use24Hours ?? true,
|
||||||
minDate: config.minDate,
|
minDate: config.minDate,
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,11 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 라벨: style.labelText 우선, 없으면 component.label 사용
|
// 라벨: style.labelText 우선, 없으면 component.label 사용
|
||||||
// style.labelDisplay가 false면 라벨 숨김
|
// 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로)
|
||||||
const style = component.style || {};
|
const style = component.style || {};
|
||||||
const effectiveLabel = style.labelDisplay === false ? undefined : (style.labelText || component.label);
|
const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay;
|
||||||
|
// labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김)
|
||||||
|
const effectiveLabel = labelDisplay === true ? (style.labelText || component.label) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<V2Input
|
<V2Input
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,25 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔧 DynamicComponentRenderer에서 전달한 style/size를 우선 사용 (height 포함)
|
||||||
|
// restProps.style에 mergedStyle(height 변환됨)이 있고, restProps.size에도 size가 있음
|
||||||
|
const effectiveStyle = restProps.style || component.style;
|
||||||
|
const effectiveSize = restProps.size || component.size;
|
||||||
|
|
||||||
|
// 🔍 디버깅: props 확인 (warn으로 변경하여 캡처되도록)
|
||||||
|
console.warn("🔍 [V2SelectRenderer] props 디버깅:", {
|
||||||
|
componentId: component.id,
|
||||||
|
"component.style": component.style,
|
||||||
|
"component.size": component.size,
|
||||||
|
"restProps.style": restProps.style,
|
||||||
|
"restProps.size": restProps.size,
|
||||||
|
effectiveStyle,
|
||||||
|
effectiveSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함)
|
||||||
|
const { style: _style, size: _size, ...restPropsClean } = restProps as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<V2Select
|
<V2Select
|
||||||
id={component.id}
|
id={component.id}
|
||||||
|
|
@ -63,12 +82,12 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
||||||
entityLabelColumn: config.entityLabelColumn,
|
entityLabelColumn: config.entityLabelColumn,
|
||||||
entityValueColumn: config.entityValueColumn,
|
entityValueColumn: config.entityValueColumn,
|
||||||
}}
|
}}
|
||||||
style={component.style}
|
|
||||||
size={component.size}
|
|
||||||
tableName={tableName}
|
tableName={tableName}
|
||||||
columnName={columnName}
|
columnName={columnName}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
{...restProps}
|
{...restPropsClean}
|
||||||
|
style={effectiveStyle}
|
||||||
|
size={effectiveSize}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,10 @@ export const V2SelectDefinition = createComponentDefinition({
|
||||||
{ value: "dropdown", label: "드롭다운" },
|
{ value: "dropdown", label: "드롭다운" },
|
||||||
{ value: "combobox", label: "콤보박스 (검색)" },
|
{ value: "combobox", label: "콤보박스 (검색)" },
|
||||||
{ value: "radio", label: "라디오 버튼" },
|
{ value: "radio", label: "라디오 버튼" },
|
||||||
{ value: "checkbox", label: "체크박스" },
|
{ value: "check", label: "체크박스" },
|
||||||
|
{ value: "tag", label: "태그" },
|
||||||
|
{ value: "toggle", label: "토글" },
|
||||||
|
{ value: "swap", label: "스왑 (좌우 이동)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
|
|
|
||||||
|
|
@ -84,23 +84,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={componentStyle} className={className} {...domProps}>
|
<div style={componentStyle} className={className} {...domProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* v2-text-display는 텍스트 표시 전용이므로 별도 라벨 불필요 */}
|
||||||
{component.label && (component.style?.labelDisplay ?? true) && (
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "-25px",
|
|
||||||
left: "0px",
|
|
||||||
fontSize: component.style?.labelFontSize || "14px",
|
|
||||||
color: component.style?.labelColor || "#64748b",
|
|
||||||
fontWeight: "500",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{component.label}
|
|
||||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={textStyle} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
<div style={textStyle} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||||
{componentConfig.text || "텍스트를 입력하세요"}
|
{componentConfig.text || "텍스트를 입력하세요"}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1620,7 +1620,16 @@ export class ButtonActionExecutor {
|
||||||
if (config.enableDataflowControl && config.dataflowConfig) {
|
if (config.enableDataflowControl && config.dataflowConfig) {
|
||||||
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우)
|
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우)
|
||||||
// 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨
|
// 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨
|
||||||
const formData: Record<string, any> = (saveResult.data || context.formData || {}) as Record<string, any>;
|
// 🔧 수정: saveResult.data가 3단계로 중첩된 경우 실제 폼 데이터 추출
|
||||||
|
// saveResult.data = API 응답 { success, data, message }
|
||||||
|
// saveResult.data.data = 저장된 레코드 { id, screenId, tableName, data, createdAt... }
|
||||||
|
// saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... }
|
||||||
|
const savedRecord = saveResult?.data?.data || saveResult?.data || {};
|
||||||
|
const actualFormData = savedRecord?.data || savedRecord;
|
||||||
|
const formData: Record<string, any> = (Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}) as Record<string, any>;
|
||||||
|
console.log("📦 [executeAfterSaveControl] savedRecord 구조:", Object.keys(savedRecord));
|
||||||
|
console.log("📦 [executeAfterSaveControl] actualFormData 추출:", Object.keys(formData));
|
||||||
|
console.log("📦 [executeAfterSaveControl] formData.sabun:", formData.sabun);
|
||||||
let parsedSectionData: any[] = [];
|
let parsedSectionData: any[] = [];
|
||||||
|
|
||||||
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
|
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
|
||||||
|
|
@ -4025,16 +4034,27 @@ export class ButtonActionExecutor {
|
||||||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||||
|
|
||||||
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||||||
// 우선순위: selectedRowsData > savedData > formData
|
// 🔧 저장 후 제어: savedData > formData > selectedRowsData
|
||||||
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
// - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요!
|
||||||
// - savedData: 저장 API 응답 데이터
|
// - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위
|
||||||
// - formData: 폼에 입력된 데이터
|
|
||||||
let sourceData: any[];
|
let sourceData: any[];
|
||||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
if (context.savedData) {
|
||||||
|
// 저장된 데이터가 있으면 우선 사용 (저장 API 응답)
|
||||||
|
sourceData = Array.isArray(context.savedData) ? context.savedData : [context.savedData];
|
||||||
|
console.log("📦 [executeAfterSaveControl] savedData 사용:", sourceData);
|
||||||
|
console.log("📦 [executeAfterSaveControl] savedData 필드:", Object.keys(context.savedData));
|
||||||
|
console.log("📦 [executeAfterSaveControl] savedData.sabun:", context.savedData.sabun);
|
||||||
|
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||||
|
// 폼 데이터 사용
|
||||||
|
sourceData = [context.formData];
|
||||||
|
console.log("📦 [executeAfterSaveControl] formData 사용:", sourceData);
|
||||||
|
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
// 테이블 섹션 데이터 (마지막 순위)
|
||||||
sourceData = context.selectedRowsData;
|
sourceData = context.selectedRowsData;
|
||||||
|
console.log("📦 [executeAfterSaveControl] selectedRowsData 사용:", sourceData);
|
||||||
} else {
|
} else {
|
||||||
const savedData = context.savedData || context.formData || {};
|
sourceData = [];
|
||||||
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
console.warn("⚠️ [executeAfterSaveControl] 데이터 소스 없음!");
|
||||||
}
|
}
|
||||||
|
|
||||||
let allSuccess = true;
|
let allSuccess = true;
|
||||||
|
|
@ -4134,16 +4154,25 @@ export class ButtonActionExecutor {
|
||||||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||||
|
|
||||||
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||||||
// 우선순위: selectedRowsData > savedData > formData
|
// 🔧 저장 후 제어: savedData > formData > selectedRowsData
|
||||||
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
// - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요!
|
||||||
// - savedData: 저장 API 응답 데이터
|
// - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위
|
||||||
// - formData: 폼에 입력된 데이터
|
|
||||||
let sourceData: any[];
|
let sourceData: any[];
|
||||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
if (context.savedData) {
|
||||||
|
// 저장된 데이터가 있으면 우선 사용 (저장 API 응답)
|
||||||
|
sourceData = Array.isArray(context.savedData) ? context.savedData : [context.savedData];
|
||||||
|
console.log("📦 [executeSingleFlowControl] savedData 사용:", sourceData);
|
||||||
|
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||||
|
// 폼 데이터 사용
|
||||||
|
sourceData = [context.formData];
|
||||||
|
console.log("📦 [executeSingleFlowControl] formData 사용:", sourceData);
|
||||||
|
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
// 테이블 섹션 데이터 (마지막 순위)
|
||||||
sourceData = context.selectedRowsData;
|
sourceData = context.selectedRowsData;
|
||||||
|
console.log("📦 [executeSingleFlowControl] selectedRowsData 사용:", sourceData);
|
||||||
} else {
|
} else {
|
||||||
const savedData = context.savedData || context.formData || {};
|
sourceData = [];
|
||||||
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
console.warn("⚠️ [executeSingleFlowControl] 데이터 소스 없음!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// repeat-screen-modal 데이터가 있으면 병합
|
// repeat-screen-modal 데이터가 있으면 병합
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,20 @@ export interface ButtonExecutionResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ControlConfig {
|
interface ControlConfig {
|
||||||
type: "relationship";
|
type: "relationship" | "flow";
|
||||||
relationshipConfig: {
|
relationshipConfig?: {
|
||||||
relationshipId: string;
|
relationshipId: string;
|
||||||
relationshipName: string;
|
relationshipName: string;
|
||||||
executionTiming: "before" | "after" | "replace";
|
executionTiming: "before" | "after" | "replace";
|
||||||
contextData?: Record<string, any>;
|
contextData?: Record<string, any>;
|
||||||
};
|
};
|
||||||
|
// 🆕 플로우 기반 제어 설정
|
||||||
|
flowConfig?: {
|
||||||
|
flowId: number;
|
||||||
|
flowName: string;
|
||||||
|
executionTiming: "before" | "after" | "replace";
|
||||||
|
contextData?: Record<string, any>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExecutionPlan {
|
interface ExecutionPlan {
|
||||||
|
|
@ -163,15 +170,22 @@ export class ImprovedButtonActionExecutor {
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
// enableDataflowControl 체크를 제거하고 dataflowConfig만 있으면 실행
|
// 🔧 controlMode가 없으면 flowConfig/relationshipConfig 존재 여부로 자동 판단
|
||||||
|
const effectiveControlMode = dataflowConfig.controlMode
|
||||||
|
|| (dataflowConfig.flowConfig ? "flow" : null)
|
||||||
|
|| (dataflowConfig.relationshipConfig ? "relationship" : null)
|
||||||
|
|| "none";
|
||||||
|
|
||||||
console.log("📋 실행 계획 생성:", {
|
console.log("📋 실행 계획 생성:", {
|
||||||
controlMode: dataflowConfig.controlMode,
|
controlMode: dataflowConfig.controlMode,
|
||||||
|
effectiveControlMode,
|
||||||
|
hasFlowConfig: !!dataflowConfig.flowConfig,
|
||||||
hasRelationshipConfig: !!dataflowConfig.relationshipConfig,
|
hasRelationshipConfig: !!dataflowConfig.relationshipConfig,
|
||||||
enableDataflowControl: buttonConfig.enableDataflowControl,
|
enableDataflowControl: buttonConfig.enableDataflowControl,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 관계 기반 제어만 지원
|
// 관계 기반 제어
|
||||||
if (dataflowConfig.controlMode === "relationship" && dataflowConfig.relationshipConfig) {
|
if (effectiveControlMode === "relationship" && dataflowConfig.relationshipConfig) {
|
||||||
const control: ControlConfig = {
|
const control: ControlConfig = {
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
relationshipConfig: dataflowConfig.relationshipConfig,
|
relationshipConfig: dataflowConfig.relationshipConfig,
|
||||||
|
|
@ -191,11 +205,34 @@ export class ImprovedButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 플로우 기반 제어
|
||||||
|
if (effectiveControlMode === "flow" && dataflowConfig.flowConfig) {
|
||||||
|
const control: ControlConfig = {
|
||||||
|
type: "flow",
|
||||||
|
flowConfig: dataflowConfig.flowConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📋 플로우 제어 설정:", dataflowConfig.flowConfig);
|
||||||
|
|
||||||
|
switch (dataflowConfig.flowConfig.executionTiming) {
|
||||||
|
case "before":
|
||||||
|
plan.beforeControls.push(control);
|
||||||
|
break;
|
||||||
|
case "after":
|
||||||
|
plan.afterControls.push(control);
|
||||||
|
break;
|
||||||
|
case "replace":
|
||||||
|
plan.afterControls.push(control);
|
||||||
|
plan.hasReplaceControl = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 제어 실행 (관계 또는 외부호출)
|
* 🔥 제어 실행 (관계 또는 플로우)
|
||||||
*/
|
*/
|
||||||
private static async executeControls(
|
private static async executeControls(
|
||||||
controls: ControlConfig[],
|
controls: ControlConfig[],
|
||||||
|
|
@ -206,8 +243,16 @@ export class ImprovedButtonActionExecutor {
|
||||||
|
|
||||||
for (const control of controls) {
|
for (const control of controls) {
|
||||||
try {
|
try {
|
||||||
// 관계 실행만 지원
|
let result: ExecutionResult;
|
||||||
const result = await this.executeRelationship(control.relationshipConfig, formData, context);
|
|
||||||
|
// 🆕 제어 타입에 따라 분기 처리
|
||||||
|
if (control.type === "flow" && control.flowConfig) {
|
||||||
|
result = await this.executeFlow(control.flowConfig, formData, context);
|
||||||
|
} else if (control.type === "relationship" && control.relationshipConfig) {
|
||||||
|
result = await this.executeRelationship(control.relationshipConfig, formData, context);
|
||||||
|
} else {
|
||||||
|
throw new Error(`지원하지 않는 제어 타입: ${control.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
results.push(result);
|
results.push(result);
|
||||||
|
|
||||||
|
|
@ -215,7 +260,7 @@ export class ImprovedButtonActionExecutor {
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.message);
|
throw new Error(result.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(`제어 실행 실패 (${control.type}):`, error);
|
console.error(`제어 실행 실패 (${control.type}):`, error);
|
||||||
results.push({
|
results.push({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -230,6 +275,61 @@ export class ImprovedButtonActionExecutor {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 플로우 실행
|
||||||
|
*/
|
||||||
|
private static async executeFlow(
|
||||||
|
config: {
|
||||||
|
flowId: number;
|
||||||
|
flowName: string;
|
||||||
|
executionTiming: "before" | "after" | "replace";
|
||||||
|
contextData?: Record<string, any>;
|
||||||
|
},
|
||||||
|
formData: Record<string, any>,
|
||||||
|
context: ButtonExecutionContext,
|
||||||
|
): Promise<ExecutionResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`🔄 플로우 실행 시작: ${config.flowName} (ID: ${config.flowId})`);
|
||||||
|
|
||||||
|
// 플로우 실행 API 호출
|
||||||
|
const response = await apiClient.post(`/api/dataflow/node-flows/${config.flowId}/execute`, {
|
||||||
|
formData,
|
||||||
|
contextData: config.contextData || {},
|
||||||
|
selectedRows: context.selectedRows || [],
|
||||||
|
flowSelectedData: context.flowSelectedData || [],
|
||||||
|
screenId: context.screenId,
|
||||||
|
companyCode: context.companyCode,
|
||||||
|
userId: context.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const executionTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
console.log(`✅ 플로우 실행 성공: ${config.flowName}`, response.data);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `플로우 "${config.flowName}" 실행 완료`,
|
||||||
|
executionTime,
|
||||||
|
data: response.data,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data?.message || "플로우 실행 실패");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const executionTime = Date.now() - startTime;
|
||||||
|
console.error(`❌ 플로우 실행 실패: ${config.flowName}`, error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `플로우 "${config.flowName}" 실행 실패: ${error.message}`,
|
||||||
|
executionTime,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 관계 실행
|
* 🔥 관계 실행
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,8 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
||||||
autoFill: overrides.autoFill,
|
autoFill: overrides.autoFill,
|
||||||
// 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등)
|
// 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등)
|
||||||
style: overrides.style || {},
|
style: overrides.style || {},
|
||||||
|
// 🔧 webTypeConfig 복원 (버튼 제어기능, 플로우 가시성 등)
|
||||||
|
webTypeConfig: overrides.webTypeConfig || {},
|
||||||
// 기존 구조 호환을 위한 추가 필드
|
// 기존 구조 호환을 위한 추가 필드
|
||||||
parentId: null,
|
parentId: null,
|
||||||
gridColumns: 12,
|
gridColumns: 12,
|
||||||
|
|
@ -245,13 +247,47 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
||||||
if (comp.autoFill) topLevelProps.autoFill = comp.autoFill;
|
if (comp.autoFill) topLevelProps.autoFill = comp.autoFill;
|
||||||
// 🆕 style 설정 저장 (라벨 텍스트, 라벨 스타일 등)
|
// 🆕 style 설정 저장 (라벨 텍스트, 라벨 스타일 등)
|
||||||
if (comp.style && Object.keys(comp.style).length > 0) topLevelProps.style = comp.style;
|
if (comp.style && Object.keys(comp.style).length > 0) topLevelProps.style = comp.style;
|
||||||
|
// 🔧 webTypeConfig 저장 (버튼 제어기능, 플로우 가시성 등)
|
||||||
|
if (comp.webTypeConfig && Object.keys(comp.webTypeConfig).length > 0) {
|
||||||
|
topLevelProps.webTypeConfig = comp.webTypeConfig;
|
||||||
|
// 🔍 디버그: webTypeConfig 저장 확인
|
||||||
|
if (comp.webTypeConfig.dataflowConfig || comp.webTypeConfig.enableDataflowControl) {
|
||||||
|
console.log("💾 webTypeConfig 저장:", {
|
||||||
|
componentId: comp.id,
|
||||||
|
enableDataflowControl: comp.webTypeConfig.enableDataflowControl,
|
||||||
|
dataflowConfig: comp.webTypeConfig.dataflowConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 설정에서 차이값만 추출
|
// 현재 설정에서 차이값만 추출
|
||||||
const fullConfig = comp.componentConfig || {};
|
const fullConfig = comp.componentConfig || {};
|
||||||
const configOverrides = extractCustomConfig(fullConfig, defaults);
|
const configOverrides = extractCustomConfig(fullConfig, defaults);
|
||||||
|
|
||||||
|
// 🔧 디버그: style 저장 확인 (주석 처리)
|
||||||
|
// if (comp.style?.labelDisplay !== undefined || configOverrides.style?.labelDisplay !== undefined) { console.log("💾 저장 시 style 변환:", { componentId: comp.id, "comp.style": comp.style, "configOverrides.style": configOverrides.style, "topLevelProps.style": topLevelProps.style }); }
|
||||||
|
|
||||||
// 상위 레벨 속성과 componentConfig 병합
|
// 상위 레벨 속성과 componentConfig 병합
|
||||||
const overrides = { ...topLevelProps, ...configOverrides };
|
// 🔧 style은 양쪽을 병합하되 comp.style(topLevelProps.style)을 우선시
|
||||||
|
const mergedStyle = {
|
||||||
|
...(configOverrides.style || {}),
|
||||||
|
...(topLevelProps.style || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔧 webTypeConfig도 병합 (topLevelProps가 우선, dataflowConfig 등 보존)
|
||||||
|
const mergedWebTypeConfig = {
|
||||||
|
...(configOverrides.webTypeConfig || {}),
|
||||||
|
...(topLevelProps.webTypeConfig || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const overrides = {
|
||||||
|
...topLevelProps,
|
||||||
|
...configOverrides,
|
||||||
|
// 🆕 병합된 style 사용 (comp.style 값이 최종 우선)
|
||||||
|
...(Object.keys(mergedStyle).length > 0 ? { style: mergedStyle } : {}),
|
||||||
|
// 🆕 병합된 webTypeConfig 사용 (comp.webTypeConfig가 최종 우선)
|
||||||
|
...(Object.keys(mergedWebTypeConfig).length > 0 ? { webTypeConfig: mergedWebTypeConfig } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: comp.id,
|
id: comp.id,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ const nextConfig = {
|
||||||
|
|
||||||
// 실험적 기능 활성화
|
// 실험적 기능 활성화
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: undefined,
|
// 메모리 사용량 최적화 (Next.js 15+)
|
||||||
|
webpackMemoryOptimizations: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// API 프록시 설정 - 백엔드로 요청 전달
|
// API 프록시 설정 - 백엔드로 요청 전달
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue