Compare commits

...

7 Commits

Author SHA1 Message Date
DDD1542 9f3437d499 Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-renewal
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-05 14:08:38 +09:00
DDD1542 dd867efd0a feat: ScreenModal 및 V2Select 컴포넌트 개선
- ScreenModal에서 모달 크기 계산 로직을 개선하여, 콘텐츠가 화면 높이를 초과할 때만 스크롤이 필요하도록 수정하였습니다.
- V2Select 및 관련 컴포넌트에서 height 및 style props를 추가하여, 사용자 정의 스타일을 보다 효과적으로 적용할 수 있도록 하였습니다.
- DropdownSelect에서 height 스타일을 직접 전달하여, 다양한 높이 설정을 지원하도록 개선하였습니다.
- CategorySelectComponent에서 라벨 표시 및 높이 계산 로직을 추가하여, 사용자 경험을 향상시켰습니다.
2026-02-05 14:07:18 +09:00
DDD1542 1de67a88b5 Merge conflict resolved: V2Media.tsx
원격 버전(7ec5a43) 채택 - 완전한 인라인 UI 코드 사용
2026-02-05 10:20:22 +09:00
DDD1542 f3a0c92564 feat: EditModal 및 ButtonActionExecutor에서 데이터 흐름 제어 로직 개선
- EditModal 컴포넌트에서 executionTiming 체크 로직을 추가하여 데이터 흐름 제어를 보다 유연하게 처리하도록 개선하였습니다.
- ButtonActionExecutor에서 저장된 데이터 구조를 명확히 하여, API 응답에서 실제 폼 데이터를 올바르게 추출하도록 수정하였습니다.
- 디버깅 로그를 추가하여 데이터 흐름 및 상태를 추적할 수 있도록 하여 개발 편의성을 높였습니다.
2026-02-05 10:08:26 +09:00
DDD1542 32139beebc feat: Docker 및 컴포넌트 최적화
- Docker Compose 설정에서 Node.js 메모리 제한을 8192MB로 증가시키고, Next.js telemetry를 비활성화하여 성능을 개선하였습니다.
- Next.js 구성에서 메모리 사용량 최적화를 위한 webpackMemoryOptimizations를 활성화하였습니다.
- ScreenModal 컴포넌트에서 overflow 속성을 조정하여 라벨이 잘리지 않도록 개선하였습니다.
- InteractiveScreenViewerDynamic 컴포넌트에서 라벨 표시 여부를 확인하는 로직을 추가하여 사용자 경험을 향상시켰습니다.
- RealtimePreviewDynamic 컴포넌트에서 라벨 표시 및 디버깅 로그를 추가하여 렌더링 과정을 추적할 수 있도록 하였습니다.
- ImprovedButtonControlConfigPanel에서 controlMode 설정을 추가하여 플로우 제어 기능을 개선하였습니다.
- V2PropertiesPanel에서 라벨 텍스트 및 표시 상태 업데이트 로직을 개선하여 일관성을 높였습니다.
- DynamicComponentRenderer에서 라벨 표시 로직을 개선하여 사용자 정의 스타일을 보다 효과적으로 적용할 수 있도록 하였습니다.
- layoutV2Converter에서 webTypeConfig를 병합하여 버튼 제어 기능과 플로우 가시성을 보존하였습니다.
2026-02-04 18:01:20 +09:00
DDD1542 593209e26e Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-renewal
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-04 11:27:03 +09:00
DDD1542 942eb079e8 feat: ScreenModal 및 V2 컴포넌트 레이아웃 개선
- ScreenModal 컴포넌트의 모달 크기 조정을 위해 헤더 및 푸터 높이를 수정하고, 여백을 최소화하여 디자인 일치를 도모하였습니다.
- V2 컴포넌트에서 라벨 높이를 계산하여 절대 위치로 배치하도록 변경하였으며, 입력 필드 및 선택 컴포넌트의 구조를 개선하여 일관된 사용자 경험을 제공하였습니다.
- 플레이스홀더 기능을 추가하여 날짜 선택 시 사용자 편의성을 높였습니다.
- 관련 CSS 클래스를 업데이트하여 반응형 디자인을 강화하였습니다.
2026-02-04 11:26:51 +09:00
31 changed files with 864 additions and 311 deletions

View File

@ -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;
} }

View File

@ -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

View File

@ -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 (부서관리 비밀번호 자동세팅)

View File

@ -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",
}} }}
> >
{(() => { {(() => {

View File

@ -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");

View File

@ -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>

View File

@ -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

View File

@ -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 설정 (디버깅용)

View File

@ -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)

View File

@ -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,

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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>
); );
}; };

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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}
/> />
); );
} }

View File

@ -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: {

View File

@ -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>

View File

@ -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 데이터가 있으면 병합

View File

@ -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,
};
}
}
/** /**
* 🔥 * 🔥
*/ */

View File

@ -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,

View File

@ -15,7 +15,8 @@ const nextConfig = {
// 실험적 기능 활성화 // 실험적 기능 활성화
experimental: { experimental: {
outputFileTracingRoot: undefined, // 메모리 사용량 최적화 (Next.js 15+)
webpackMemoryOptimizations: true,
}, },
// API 프록시 설정 - 백엔드로 요청 전달 // API 프록시 설정 - 백엔드로 요청 전달