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(
|
||||
`📊 컨텍스트 데이터 사용: ${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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ services:
|
|||
- "9771:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||
- NODE_OPTIONS=--max-old-space-size=8192
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
volumes:
|
||||
- ../../frontend:/app
|
||||
- /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 {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
||||
needsScroll: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + padding
|
||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
const dialogGap = 16; // DialogContent gap-4
|
||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
|
||||
// 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
|
||||
const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const footerHeight = 44; // 연속 등록 모드 체크박스 영역
|
||||
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 {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
||||
width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
|
||||
// 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
|
||||
maxHeight: `${maxAvailableHeight}px`,
|
||||
maxWidth: "98vw",
|
||||
maxHeight: "95vh",
|
||||
},
|
||||
needsScroll,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -618,7 +626,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
return (
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
||||
{...(modalStyle.style && { style: modalStyle.style })}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
|
|
@ -633,7 +641,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</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 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -645,11 +655,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}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,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// buttonActions의 executeAfterSaveControl 동적 import
|
||||
|
|
@ -865,7 +870,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
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);
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
|
@ -938,7 +948,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
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);
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
|
|
|||
|
|
@ -1067,22 +1067,35 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||
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 = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0,
|
||||
top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
|
||||
zIndex: position?.z || 1,
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
|
||||
overflow: labelOffset > 0 ? "visible" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
||||
{/* 위젯 렌더링 */}
|
||||
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
|
||||
{renderInteractiveWidget(component)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@ const WidgetRenderer: React.FC<{
|
|||
tableDisplayData?: any[];
|
||||
[key: string]: any;
|
||||
}> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => {
|
||||
// 🔧 무조건 로그 (렌더링 확인용)
|
||||
console.log("📦 WidgetRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
|
||||
|
||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||
if (!isWidgetComponent(component)) {
|
||||
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
||||
|
|
@ -127,9 +130,6 @@ const WidgetRenderer: React.FC<{
|
|||
const widget = component;
|
||||
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);
|
||||
|
||||
|
|
@ -246,8 +246,17 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
tableDisplayData, // 🆕 화면 표시 데이터
|
||||
...restProps
|
||||
}) => {
|
||||
// 🔧 무조건 로그 - 파일 반영 테스트용 (2024-TEST)
|
||||
console.log("🔷🔷🔷 RealtimePreview 2024:", component.id);
|
||||
|
||||
const { user } = useAuth();
|
||||
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 [actualHeight, setActualHeight] = useState<number | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -741,6 +750,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
||||
{type === "component" &&
|
||||
(() => {
|
||||
console.log("📦 DynamicComponentRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
|
||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
|
|
|
|||
|
|
@ -598,12 +598,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
(contentRef as any).current = node;
|
||||
}
|
||||
}}
|
||||
className={`${
|
||||
(component.type === "component" && (component as any).componentType === "flow-widget") ||
|
||||
((component as any).componentType === "conditional-container" && !isDesignMode)
|
||||
? "h-auto"
|
||||
: "h-full"
|
||||
} overflow-visible`}
|
||||
className="h-full overflow-visible"
|
||||
style={{ width: "100%", maxWidth: "100%" }}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
|
|
@ -649,9 +644,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
{/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */}
|
||||
{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" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{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);
|
||||
|
||||
// displayName 설정 (디버깅용)
|
||||
|
|
|
|||
|
|
@ -472,14 +472,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
// 이미 배치된 컬럼 목록 계산
|
||||
const placedColumns = useMemo(() => {
|
||||
const placed = new Set<string>();
|
||||
// 🔧 화면의 메인 테이블명을 fallback으로 사용
|
||||
const screenTableName = selectedScreen?.tableName;
|
||||
|
||||
const collectColumns = (components: ComponentData[]) => {
|
||||
components.forEach((comp) => {
|
||||
const anyComp = comp as any;
|
||||
|
||||
// widget 타입 또는 component 타입 (새로운 시스템)에서 tableName과 columnName 확인
|
||||
if ((comp.type === "widget" || comp.type === "component") && anyComp.tableName && anyComp.columnName) {
|
||||
const key = `${anyComp.tableName}.${anyComp.columnName}`;
|
||||
// 🔧 tableName과 columnName을 여러 위치에서 찾기 (최상위, componentConfig, 또는 화면 테이블명)
|
||||
const tableName = anyComp.tableName || anyComp.componentConfig?.tableName || screenTableName;
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -492,7 +498,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
collectColumns(layout.components);
|
||||
return placed;
|
||||
}, [layout.components]);
|
||||
}, [layout.components, selectedScreen?.tableName]);
|
||||
|
||||
// 히스토리에 저장
|
||||
const saveToHistory = useCallback(
|
||||
|
|
@ -770,6 +776,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
const finalKey = pathParts[pathParts.length - 1];
|
||||
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도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
|
||||
if (path === "size.width" || path === "size.height" || path === "size") {
|
||||
// 🔧 style 객체를 새로 복사하여 불변성 유지
|
||||
|
|
@ -1787,97 +1809,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
const buttonComponents = layoutWithResolution.components.filter(
|
||||
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
||||
);
|
||||
console.log("💾 저장 시작:", {
|
||||
screenId: selectedScreen.screenId,
|
||||
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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
});
|
||||
// 💾 저장 로그 (디버그 완료 - 간소화)
|
||||
// console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length });
|
||||
// 분할 패널 디버그 로그 (주석 처리)
|
||||
|
||||
// V2 API 사용 여부에 따라 분기
|
||||
if (USE_V2_API) {
|
||||
// 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
|
||||
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);
|
||||
console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
||||
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
||||
console.log("✅ 저장 성공! 메뉴 할당 모달 열기");
|
||||
// console.log("✅ 저장 성공!");
|
||||
toast.success("화면이 저장되었습니다.");
|
||||
|
||||
// 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영)
|
||||
|
|
@ -3084,7 +3030,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
},
|
||||
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
||||
style: {
|
||||
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
||||
labelDisplay: true, // 🆕 라벨 기본 표시 (사용자가 끄고 싶으면 체크 해제)
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
|
|
@ -3750,7 +3696,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
entityJoinColumn: column.entityJoinColumn,
|
||||
}),
|
||||
style: {
|
||||
labelDisplay: false, // 라벨 숨김
|
||||
labelDisplay: true, // 🆕 라벨 기본 표시
|
||||
labelFontSize: "12px",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
|
|
@ -3816,7 +3762,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
entityJoinColumn: column.entityJoinColumn,
|
||||
}),
|
||||
style: {
|
||||
labelDisplay: false, // 라벨 숨김
|
||||
labelDisplay: true, // 🆕 라벨 기본 표시
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#000000", // 순수한 검정
|
||||
labelFontWeight: "500",
|
||||
|
|
@ -5452,6 +5398,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
);
|
||||
}
|
||||
|
||||
// 🔧 ScreenDesigner 렌더링 확인 (디버그 완료 - 주석 처리)
|
||||
// console.log("🏠 ScreenDesigner 렌더!", Date.now());
|
||||
|
||||
return (
|
||||
<ScreenPreviewProvider isPreviewMode={false}>
|
||||
<TableOptionsProvider>
|
||||
|
|
@ -6163,6 +6112,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
// 그룹에 속하지 않은 일반 컴포넌트들
|
||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||||
|
||||
// 🔧 렌더링 확인 로그 (디버그 완료 - 주석 처리)
|
||||
// console.log("🔄 ScreenDesigner 렌더링:", { componentsCount: regularComponents.length, forceRenderTrigger, timestamp: Date.now() });
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
|
|
@ -6228,11 +6180,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
const globalFiles = globalFileState[component.id] || [];
|
||||
const componentFiles = (component as any).uploadedFiles || [];
|
||||
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
|
||||
// 🆕 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 (
|
||||
<RealtimePreview
|
||||
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`}
|
||||
component={displayComponent}
|
||||
key={fullKey}
|
||||
component={componentWithLabel}
|
||||
isSelected={
|
||||
selectedComponent?.id === component.id ||
|
||||
groupState.selectedComponents.includes(component.id)
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
||||
...dataflowConfig,
|
||||
// 🔧 controlMode 설정 (플로우 제어가 있으면 "flow", 없으면 "none")
|
||||
controlMode: firstValidControl ? "flow" : "none",
|
||||
// 기존 형식 (하위 호환성)
|
||||
selectedDiagramId: firstValidControl?.flowId || null,
|
||||
selectedRelationshipId: null,
|
||||
|
|
|
|||
|
|
@ -823,7 +823,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨 텍스트</Label>
|
||||
<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) => {
|
||||
handleUpdate("style.labelText", e.target.value);
|
||||
handleUpdate("label", e.target.value); // label도 함께 업데이트
|
||||
|
|
@ -862,8 +862,23 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
</div>
|
||||
<div className="flex items-center space-x-2 pt-5">
|
||||
<Checkbox
|
||||
checked={selectedComponent.style?.labelDisplay !== false}
|
||||
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
|
||||
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
|
||||
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"
|
||||
/>
|
||||
<Label className="text-xs">표시</Label>
|
||||
|
|
|
|||
|
|
@ -22,18 +22,25 @@ function SelectTrigger({
|
|||
className,
|
||||
size = "xs",
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "xs" | "sm" | "default";
|
||||
}) {
|
||||
// className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시
|
||||
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height;
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
data-size={hasCustomHeight ? undefined : size}
|
||||
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",
|
||||
// 커스텀 높이일 때 기본 패딩 적용
|
||||
hasCustomHeight && "px-2 py-1",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -311,36 +311,39 @@ export const V2Biz = forwardRef<HTMLDivElement, V2BizProps>(
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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>
|
||||
)}
|
||||
<div
|
||||
className="min-h-0"
|
||||
style={{
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
{renderBiz()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,10 +75,11 @@ const SingleDatePicker = forwardRef<
|
|||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
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,
|
||||
) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -87,6 +88,16 @@ const SingleDatePicker = forwardRef<
|
|||
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, 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(
|
||||
(selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
|
|
@ -115,13 +126,13 @@ const SingleDatePicker = forwardRef<
|
|||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"h-10 w-full justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
"h-full w-full justify-start text-left font-normal",
|
||||
!displayText && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value || "날짜 선택"}
|
||||
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
{displayText || placeholder}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
|
|
@ -211,14 +222,14 @@ const RangeDatePicker = forwardRef<
|
|||
);
|
||||
|
||||
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}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
{value[0] || "시작일"}
|
||||
|
|
@ -248,7 +259,7 @@ const RangeDatePicker = forwardRef<
|
|||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
{value[1] || "종료일"}
|
||||
|
|
@ -290,7 +301,7 @@ const TimePicker = forwardRef<
|
|||
}
|
||||
>(({ value, onChange, disabled, readonly, className }, ref) => {
|
||||
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" />
|
||||
<Input
|
||||
ref={ref}
|
||||
|
|
@ -299,7 +310,7 @@ const TimePicker = forwardRef<
|
|||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
className="h-10 pl-10"
|
||||
className="h-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -346,8 +357,8 @@ const DateTimePicker = forwardRef<
|
|||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex gap-2", className)}>
|
||||
<div className="flex-1">
|
||||
<div ref={ref} className={cn("flex gap-2 h-full", className)}>
|
||||
<div className="flex-1 h-full">
|
||||
<SingleDatePicker
|
||||
value={datePart}
|
||||
onChange={handleDateChange}
|
||||
|
|
@ -358,7 +369,7 @@ const DateTimePicker = forwardRef<
|
|||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<div className="w-1/3 min-w-[100px] h-full">
|
||||
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -409,6 +420,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
maxDate={config.maxDate}
|
||||
disabled={isDisabled}
|
||||
readonly={readonly}
|
||||
placeholder={config.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -444,6 +456,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
showToday={config.showToday}
|
||||
disabled={isDisabled}
|
||||
readonly={readonly}
|
||||
placeholder={config.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -453,37 +466,40 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div
|
||||
className="min-h-0"
|
||||
style={{
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
{renderDatePicker()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -462,37 +462,40 @@ export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>(
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div
|
||||
className="min-h-0"
|
||||
style={{
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
{renderHierarchy()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@ const TextareaInput = forwardRef<
|
|||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
|
|
@ -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에서 가져옴
|
||||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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>}
|
||||
</Label>
|
||||
)}
|
||||
<div
|
||||
className="min-h-0"
|
||||
style={{
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
{renderInput()}
|
||||
</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"
|
||||
style={{
|
||||
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>
|
||||
|
|
@ -149,7 +149,7 @@ export const V2List = forwardRef<HTMLDivElement, V2ListProps>((props, ref) => {
|
|||
className="flex flex-col overflow-auto"
|
||||
style={{
|
||||
width: size?.width || style?.width || "100%",
|
||||
height: size?.height || style?.height || 400,
|
||||
height: size?.height || style?.height || "100%",
|
||||
}}
|
||||
>
|
||||
<TableListComponent
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
allowClear?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}>(({
|
||||
options,
|
||||
value,
|
||||
|
|
@ -52,7 +53,8 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
maxSelect,
|
||||
allowClear = true,
|
||||
disabled,
|
||||
className
|
||||
className,
|
||||
style,
|
||||
}, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
|
@ -64,7 +66,8 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
onValueChange={(v) => onChange?.(v)}
|
||||
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} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -112,13 +115,15 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{/* Button에 style로 직접 height 전달 (Popover도 DOM 체인 끊김) */}
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
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">
|
||||
{selectedLabels.length > 0
|
||||
|
|
@ -368,9 +373,9 @@ const SwapSelect = forwardRef<HTMLDivElement, {
|
|||
return (
|
||||
<div ref={ref} className={cn("flex gap-2 items-stretch", className)}>
|
||||
{/* 왼쪽: 선택 가능 */}
|
||||
<div className="flex-1 border rounded-md">
|
||||
<div className="p-2 bg-muted text-xs font-medium border-b">선택 가능</div>
|
||||
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
||||
<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 shrink-0">선택 가능</div>
|
||||
<div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
|
||||
{available.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
|
|
@ -412,9 +417,9 @@ const SwapSelect = forwardRef<HTMLDivElement, {
|
|||
</div>
|
||||
|
||||
{/* 오른쪽: 선택됨 */}
|
||||
<div className="flex-1 border rounded-md">
|
||||
<div className="p-2 bg-primary/10 text-xs font-medium border-b">선택됨</div>
|
||||
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
||||
<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 shrink-0">선택됨</div>
|
||||
<div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
|
||||
{selected.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
|
|
@ -654,24 +659,31 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
// 모드별 컴포넌트 렌더링
|
||||
const renderSelect = () => {
|
||||
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;
|
||||
|
||||
// 명시적 높이가 있을 때만 style 전달, 없으면 undefined (기본 높이 h-6 사용)
|
||||
const heightStyle: React.CSSProperties | undefined = componentHeight
|
||||
? { height: componentHeight }
|
||||
: undefined;
|
||||
|
||||
switch (config.mode) {
|
||||
case "dropdown":
|
||||
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
|
||||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="선택"
|
||||
searchable={config.searchable}
|
||||
searchable={config.mode === "combobox" ? true : config.searchable}
|
||||
multiple={config.multiple}
|
||||
maxSelect={config.maxSelect}
|
||||
allowClear={config.allowClear}
|
||||
disabled={isDisabled}
|
||||
style={heightStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -686,6 +698,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
);
|
||||
|
||||
case "check":
|
||||
case "checkbox": // 🔧 기존 저장된 값 호환
|
||||
return (
|
||||
<CheckSelect
|
||||
options={options}
|
||||
|
|
@ -735,6 +748,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
style={heightStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -744,37 +758,50 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
// 🔧 높이는 컨테이너가 아닌 입력 필드에만 적용 (라벨 높이는 별도)
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div
|
||||
className="min-h-0"
|
||||
style={{
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
{renderSelect()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,20 @@ export const V2DateConfigPanel: React.FC<V2DateConfigPanelProps> = ({
|
|||
|
||||
<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">
|
||||
<Label className="text-xs font-medium">표시 형식</Label>
|
||||
|
|
|
|||
|
|
@ -299,14 +299,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const columnName = (component as any).columnName;
|
||||
|
||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원)
|
||||
// ⚠️ 단, componentType이 "select-basic" 또는 "v2-select"인 경우는 ComponentRegistry로 처리
|
||||
// (다중선택, 체크박스, 라디오 등 고급 모드 지원)
|
||||
if (
|
||||
(inputType === "category" || webType === "category") &&
|
||||
tableName &&
|
||||
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) {
|
||||
try {
|
||||
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 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 (
|
||||
<CategorySelectComponent
|
||||
tableName={tableName}
|
||||
|
|
@ -334,6 +341,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
disabled={isFieldDisabled}
|
||||
readonly={isFieldReadonly}
|
||||
className="w-full"
|
||||
size={categorySize}
|
||||
style={categoryStyle}
|
||||
label={categoryLabel}
|
||||
id={categoryId}
|
||||
isDesignMode={props.isDesignMode}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
@ -514,6 +526,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
componentType === "modal-repeater-table" ||
|
||||
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 = {
|
||||
component,
|
||||
isSelected,
|
||||
|
|
@ -522,11 +548,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onDragEnd,
|
||||
size: component.size || newComponent.defaultSize,
|
||||
position: component.position,
|
||||
style: finalStyle, // size를 포함한 최종 style
|
||||
config: component.componentConfig,
|
||||
componentConfig: component.componentConfig,
|
||||
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
||||
...(component.componentConfig || {}),
|
||||
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
||||
style: mergedStyle,
|
||||
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
|
||||
label: effectiveLabel,
|
||||
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
|
||||
inputType: (component as any).inputType || component.componentConfig?.inputType,
|
||||
columnName: (component as any).columnName || component.componentConfig?.columnName,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
|
@ -23,6 +24,20 @@ interface CategorySelectComponentProps {
|
|||
readonly?: boolean;
|
||||
tableName?: 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,
|
||||
tableName: propTableName,
|
||||
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[]>(
|
||||
[]
|
||||
);
|
||||
|
|
@ -97,12 +132,49 @@ export const CategorySelectComponent: React.FC<
|
|||
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) {
|
||||
return (
|
||||
<div className="flex h-10 w-full items-center justify-center rounded-md border bg-background px-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
로딩 중...
|
||||
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -110,8 +182,14 @@ export const CategorySelectComponent: React.FC<
|
|||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-10 w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 text-sm text-destructive">
|
||||
⚠️ {error}
|
||||
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -119,33 +197,44 @@ export const CategorySelectComponent: React.FC<
|
|||
// 카테고리 값이 없음
|
||||
if (categoryValues.length === 0) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled || readonly}
|
||||
required={required}
|
||||
>
|
||||
<SelectTrigger className={`w-full ${className}`}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryValues.map((categoryValue) => (
|
||||
<SelectItem
|
||||
key={categoryValue.valueId}
|
||||
value={categoryValue.valueCode}
|
||||
>
|
||||
{categoryValue.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||
{renderLabel()}
|
||||
<div className="h-full w-full">
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled || readonly}
|
||||
required={required}
|
||||
>
|
||||
<SelectTrigger className={`w-full h-full ${className}`} style={heightStyle}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryValues.map((categoryValue) => (
|
||||
<SelectItem
|
||||
key={categoryValue.valueId}
|
||||
value={categoryValue.valueCode}
|
||||
>
|
||||
{categoryValue.valueLabel}
|
||||
</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 = {
|
||||
...component.style,
|
||||
...style,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||
|
|
@ -1289,19 +1286,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
|
||||
|
||||
// 공통 버튼 스타일
|
||||
// 🔧 component.style에서 background/backgroundColor 충돌 방지
|
||||
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
|
||||
const userStyle = component.style
|
||||
? Object.fromEntries(
|
||||
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 = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "40px",
|
||||
width: buttonWidth,
|
||||
height: buttonHeight,
|
||||
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
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 (
|
||||
<V2Date
|
||||
id={component.id}
|
||||
label={component.label}
|
||||
label={effectiveLabel}
|
||||
required={component.required}
|
||||
readonly={config.readonly || component.readonly}
|
||||
disabled={config.disabled || component.disabled}
|
||||
|
|
@ -41,7 +46,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
|
|||
config={{
|
||||
dateType: config.dateType || config.webType || "date",
|
||||
format: config.format || "YYYY-MM-DD",
|
||||
placeholder: config.placeholder || "날짜 선택",
|
||||
placeholder: config.placeholder || style.placeholder || "날짜 선택",
|
||||
showTime: config.showTime || false,
|
||||
use24Hours: config.use24Hours ?? true,
|
||||
minDate: config.minDate,
|
||||
|
|
|
|||
|
|
@ -43,9 +43,11 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
|||
};
|
||||
|
||||
// 라벨: style.labelText 우선, 없으면 component.label 사용
|
||||
// style.labelDisplay가 false면 라벨 숨김
|
||||
// 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 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 (
|
||||
<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 (
|
||||
<V2Select
|
||||
id={component.id}
|
||||
|
|
@ -63,12 +82,12 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
|||
entityLabelColumn: config.entityLabelColumn,
|
||||
entityValueColumn: config.entityValueColumn,
|
||||
}}
|
||||
style={component.style}
|
||||
size={component.size}
|
||||
tableName={tableName}
|
||||
columnName={columnName}
|
||||
formData={formData}
|
||||
{...restProps}
|
||||
{...restPropsClean}
|
||||
style={effectiveStyle}
|
||||
size={effectiveSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@ export const V2SelectDefinition = createComponentDefinition({
|
|||
{ value: "dropdown", label: "드롭다운" },
|
||||
{ value: "combobox", label: "콤보박스 (검색)" },
|
||||
{ value: "radio", label: "라디오 버튼" },
|
||||
{ value: "checkbox", label: "체크박스" },
|
||||
{ value: "check", label: "체크박스" },
|
||||
{ value: "tag", label: "태그" },
|
||||
{ value: "toggle", label: "토글" },
|
||||
{ value: "swap", label: "스왑 (좌우 이동)" },
|
||||
],
|
||||
},
|
||||
source: {
|
||||
|
|
|
|||
|
|
@ -84,23 +84,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* v2-text-display는 텍스트 표시 전용이므로 별도 라벨 불필요 */}
|
||||
<div style={textStyle} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{componentConfig.text || "텍스트를 입력하세요"}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1620,7 +1620,16 @@ export class ButtonActionExecutor {
|
|||
if (config.enableDataflowControl && config.dataflowConfig) {
|
||||
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 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[] = [];
|
||||
|
||||
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
|
||||
|
|
@ -4025,16 +4034,27 @@ export class ButtonActionExecutor {
|
|||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||
|
||||
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||||
// 우선순위: selectedRowsData > savedData > formData
|
||||
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
||||
// - savedData: 저장 API 응답 데이터
|
||||
// - formData: 폼에 입력된 데이터
|
||||
// 🔧 저장 후 제어: savedData > formData > selectedRowsData
|
||||
// - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요!
|
||||
// - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위
|
||||
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;
|
||||
console.log("📦 [executeAfterSaveControl] selectedRowsData 사용:", sourceData);
|
||||
} else {
|
||||
const savedData = context.savedData || context.formData || {};
|
||||
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
||||
sourceData = [];
|
||||
console.warn("⚠️ [executeAfterSaveControl] 데이터 소스 없음!");
|
||||
}
|
||||
|
||||
let allSuccess = true;
|
||||
|
|
@ -4134,16 +4154,25 @@ export class ButtonActionExecutor {
|
|||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||
|
||||
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||||
// 우선순위: selectedRowsData > savedData > formData
|
||||
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
||||
// - savedData: 저장 API 응답 데이터
|
||||
// - formData: 폼에 입력된 데이터
|
||||
// 🔧 저장 후 제어: savedData > formData > selectedRowsData
|
||||
// - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요!
|
||||
// - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위
|
||||
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;
|
||||
console.log("📦 [executeSingleFlowControl] selectedRowsData 사용:", sourceData);
|
||||
} else {
|
||||
const savedData = context.savedData || context.formData || {};
|
||||
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
||||
sourceData = [];
|
||||
console.warn("⚠️ [executeSingleFlowControl] 데이터 소스 없음!");
|
||||
}
|
||||
|
||||
// repeat-screen-modal 데이터가 있으면 병합
|
||||
|
|
|
|||
|
|
@ -43,13 +43,20 @@ export interface ButtonExecutionResult {
|
|||
}
|
||||
|
||||
interface ControlConfig {
|
||||
type: "relationship";
|
||||
relationshipConfig: {
|
||||
type: "relationship" | "flow";
|
||||
relationshipConfig?: {
|
||||
relationshipId: string;
|
||||
relationshipName: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
// 🆕 플로우 기반 제어 설정
|
||||
flowConfig?: {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutionPlan {
|
||||
|
|
@ -163,15 +170,22 @@ export class ImprovedButtonActionExecutor {
|
|||
return plan;
|
||||
}
|
||||
|
||||
// enableDataflowControl 체크를 제거하고 dataflowConfig만 있으면 실행
|
||||
// 🔧 controlMode가 없으면 flowConfig/relationshipConfig 존재 여부로 자동 판단
|
||||
const effectiveControlMode = dataflowConfig.controlMode
|
||||
|| (dataflowConfig.flowConfig ? "flow" : null)
|
||||
|| (dataflowConfig.relationshipConfig ? "relationship" : null)
|
||||
|| "none";
|
||||
|
||||
console.log("📋 실행 계획 생성:", {
|
||||
controlMode: dataflowConfig.controlMode,
|
||||
effectiveControlMode,
|
||||
hasFlowConfig: !!dataflowConfig.flowConfig,
|
||||
hasRelationshipConfig: !!dataflowConfig.relationshipConfig,
|
||||
enableDataflowControl: buttonConfig.enableDataflowControl,
|
||||
});
|
||||
|
||||
// 관계 기반 제어만 지원
|
||||
if (dataflowConfig.controlMode === "relationship" && dataflowConfig.relationshipConfig) {
|
||||
// 관계 기반 제어
|
||||
if (effectiveControlMode === "relationship" && dataflowConfig.relationshipConfig) {
|
||||
const control: ControlConfig = {
|
||||
type: "relationship",
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 제어 실행 (관계 또는 외부호출)
|
||||
* 🔥 제어 실행 (관계 또는 플로우)
|
||||
*/
|
||||
private static async executeControls(
|
||||
controls: ControlConfig[],
|
||||
|
|
@ -206,8 +243,16 @@ export class ImprovedButtonActionExecutor {
|
|||
|
||||
for (const control of controls) {
|
||||
try {
|
||||
// 관계 실행만 지원
|
||||
const result = await this.executeRelationship(control.relationshipConfig, formData, context);
|
||||
let result: ExecutionResult;
|
||||
|
||||
// 🆕 제어 타입에 따라 분기 처리
|
||||
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);
|
||||
|
||||
|
|
@ -215,7 +260,7 @@ export class ImprovedButtonActionExecutor {
|
|||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`제어 실행 실패 (${control.type}):`, error);
|
||||
results.push({
|
||||
success: false,
|
||||
|
|
@ -230,6 +275,61 @@ export class ImprovedButtonActionExecutor {
|
|||
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,
|
||||
// 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등)
|
||||
style: overrides.style || {},
|
||||
// 🔧 webTypeConfig 복원 (버튼 제어기능, 플로우 가시성 등)
|
||||
webTypeConfig: overrides.webTypeConfig || {},
|
||||
// 기존 구조 호환을 위한 추가 필드
|
||||
parentId: null,
|
||||
gridColumns: 12,
|
||||
|
|
@ -245,13 +247,47 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
|||
if (comp.autoFill) topLevelProps.autoFill = comp.autoFill;
|
||||
// 🆕 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 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 병합
|
||||
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 {
|
||||
id: comp.id,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ const nextConfig = {
|
|||
|
||||
// 실험적 기능 활성화
|
||||
experimental: {
|
||||
outputFileTracingRoot: undefined,
|
||||
// 메모리 사용량 최적화 (Next.js 15+)
|
||||
webpackMemoryOptimizations: true,
|
||||
},
|
||||
|
||||
// API 프록시 설정 - 백엔드로 요청 전달
|
||||
|
|
|
|||
Loading…
Reference in New Issue