From 2b8a3945a1791bd0e08b7f77a951820774034ff2 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 25 Nov 2025 15:22:50 +0900 Subject: [PATCH 01/32] =?UTF-8?q?fix:=20Section=20Paper=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=98=81=EC=97=AD=EA=B3=BC=20=EC=BB=A8=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=98=81=EC=97=AD=20=EC=A0=95=EB=A0=AC=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RealtimePreview: border → outline 전환, getHeight() 함수 추가 - SectionPaperComponent: width/height 100%, overflow-auto, min-h 제거 - 모든 높이에서 선택 영역 = 컨텐츠 영역 정확히 일치 --- .../components/screen/RealtimePreview.tsx | 55 +++++++++++++------ frontend/components/screen/ScreenDesigner.tsx | 5 +- .../section-paper/SectionPaperComponent.tsx | 40 +++++++++----- 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 0270ffa8..f1ca6e7d 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -401,22 +401,10 @@ export const RealtimePreviewDynamic: React.FC = ({ // 컴포넌트 스타일 계산 const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget"); + const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper"; - // 높이 결정 로직 - let finalHeight = size?.height || 10; - if (isFlowWidget && actualHeight) { - finalHeight = actualHeight; - } - - // 🔍 디버깅: position.x 값 확인 const positionX = position?.x || 0; - console.log("🔍 RealtimePreview componentStyle 설정:", { - componentId: id, - positionX, - sizeWidth: size?.width, - styleWidth: style?.width, - willUse100Percent: positionX === 0, - }); + const positionY = position?.y || 0; // 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀) const getWidth = () => { @@ -432,20 +420,35 @@ export const RealtimePreviewDynamic: React.FC = ({ return size?.width || 200; }; + // 높이 결정 로직: style.height > actualHeight (Flow Widget) > size.height + const getHeight = () => { + // 1순위: style.height가 있으면 우선 사용 (픽셀/퍼센트 값) + if (style?.height) { + return style.height; + } + // 2순위: Flow Widget의 실제 측정 높이 + if (isFlowWidget && actualHeight) { + return actualHeight; + } + // 3순위: size.height 픽셀 값 + return size?.height || 10; + }; + const componentStyle = { position: "absolute" as const, ...style, // 먼저 적용하고 left: positionX, - top: position?.y || 0, + top: positionY, width: getWidth(), // 우선순위에 따른 너비 - height: finalHeight, + height: getHeight(), // 우선순위에 따른 높이 zIndex: position?.z || 1, // right 속성 강제 제거 right: undefined, }; // 선택된 컴포넌트 스타일 - const selectionStyle = isSelected + // Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거 + const selectionStyle = isSelected && !isSectionPaper ? { outline: "2px solid rgb(59, 130, 246)", outlineOffset: "2px", @@ -628,6 +631,24 @@ export const RealtimePreviewDynamic: React.FC = ({ )} + {/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */} + {type === "component" && (() => { + const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); + return ( + + {children} + + ); + })()} + {/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */} {type === "widget" && !isFileComponent(component) && (
diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 0127c9d1..46d6ab37 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -4603,10 +4603,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }); }} > - {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} + {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} {(component.type === "group" || component.type === "container" || - component.type === "area") && + component.type === "area" || + component.type === "component") && layout.components .filter((child) => child.parentId === component.id) .map((child) => { diff --git a/frontend/lib/registry/components/section-paper/SectionPaperComponent.tsx b/frontend/lib/registry/components/section-paper/SectionPaperComponent.tsx index 526bdfa1..fa7fc856 100644 --- a/frontend/lib/registry/components/section-paper/SectionPaperComponent.tsx +++ b/frontend/lib/registry/components/section-paper/SectionPaperComponent.tsx @@ -83,11 +83,22 @@ export function SectionPaperComponent({ ? { backgroundColor: config.customColor } : {}; + // 선택 상태 테두리 처리 (outline 사용하여 크기 영향 없음) + const selectionStyle = isDesignMode && isSelected + ? { + outline: "2px solid #3b82f6", + outlineOffset: "0px", // 크기에 영향 없이 딱 맞게 표시 + } + : {}; + return (
- {/* 디자인 모드에서 빈 상태 안내 */} - {isDesignMode && !children && ( + {/* 자식 컴포넌트들 */} + {children || (isDesignMode && (
📄 Section Paper
컴포넌트를 이곳에 배치하세요
- )} - - {/* 자식 컴포넌트들 */} - {children} + ))}
); } From a1819e749c04719d8b69ab5a35f9328c5d51a5d9 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 15:55:05 +0900 Subject: [PATCH 02/32] =?UTF-8?q?fix:=20=ED=83=AD=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20menuObjid=20=EC=A0=84=EB=8B=AC,=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90,=20=EC=84=A4=EC=A0=95=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 수정사항: 1. 탭 컴포넌트 내 자식 화면에 menuObjid와 tableName 전달 - TabsWidget에 menuObjid prop 추가 - InteractiveScreenViewerDynamic를 통해 자식 화면에 전달 - 채번 규칙 생성 시 올바른 메뉴 스코프 및 테이블명 적용 2. 백엔드: 화면 레이아웃 API에 tableName 추가 - screenManagementService.getLayout()에서 테이블명 반환 - LayoutData 타입에 tableName 필드 추가 - 채번 규칙 생성 시 tableName 검증 강화 3. 카테고리 필터링 기능 복원 - DataFilterConfigPanel에 menuObjid 전달 - getCategoryValues API 사용으로 메뉴 스코프 적용 - 새로고침 후 카테고리 값 자동 재로드 - SplitPanelLayoutConfigPanel에 menuObjid 전달 4. 선택항목 상세입력 설정 패널 포커스 문제 해결 - 로컬 입력 상태 추가로 실시간 속성 편집 패턴 적용 - 텍스트 및 라벨 입력 시 포커스 유지 5. 테이블 리스트 설정 초기화 문제 해결 - handleChange 함수에서 기존 config와 병합하여 전달 - 다른 속성 손실 방지 (columns, dataFilter 등) 버그 수정: - 채번 규칙 생성 시 빈 문자열 대신 null 전달 - 필터 설정 변경 시 컬럼 설정 초기화 방지 - 카테고리 컬럼 선택 시 셀렉트박스 표시 --- .../controllers/numberingRuleController.ts | 10 ++++ .../src/services/screenManagementService.ts | 8 ++- backend-node/src/types/screen.ts | 1 + .../numbering-rule/NumberingRuleDesigner.tsx | 2 +- .../screen/InteractiveScreenViewer.tsx | 5 +- .../screen/InteractiveScreenViewerDynamic.tsx | 3 + .../config-panels/DataFilterConfigPanel.tsx | 43 +++++++++++++-- .../components/screen/widgets/TabsWidget.tsx | 8 ++- .../SelectedItemsDetailInputConfigPanel.tsx | 55 +++++++++++++++++-- .../SplitPanelLayoutConfigPanel.tsx | 43 +++++++++++++-- .../table-list/TableListConfigPanel.tsx | 3 +- 11 files changed, 160 insertions(+), 21 deletions(-) diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 55c19353..031a1506 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -132,6 +132,16 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" }); } + // 🆕 scopeType이 'table'인 경우 tableName 필수 체크 + if (ruleConfig.scopeType === "table") { + if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") { + return res.status(400).json({ + success: false, + error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다", + }); + } + } + const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId); logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", { diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 9dbe0270..a7445637 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1418,9 +1418,9 @@ export class ScreenManagementService { console.log(`=== 레이아웃 로드 시작 ===`); console.log(`화면 ID: ${screenId}`); - // 권한 확인 - const screens = await query<{ company_code: string | null }>( - `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + // 권한 확인 및 테이블명 조회 + const screens = await query<{ company_code: string | null; table_name: string | null }>( + `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId] ); @@ -1512,11 +1512,13 @@ export class ScreenManagementService { console.log(`반환할 컴포넌트 수: ${components.length}`); console.log(`최종 격자 설정:`, gridSettings); console.log(`최종 해상도 설정:`, screenResolution); + console.log(`테이블명:`, existingScreen.table_name); return { components, gridSettings, screenResolution, + tableName: existingScreen.table_name, // 🆕 테이블명 추가 }; } diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index 304c589c..ca5a466f 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -101,6 +101,7 @@ export interface LayoutData { components: ComponentData[]; gridSettings?: GridSettings; screenResolution?: ScreenResolution; + tableName?: string; // 🆕 화면에 연결된 테이블명 } // 그리드 설정 diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 0bd49982..bfdb69c2 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -152,7 +152,7 @@ export const NumberingRuleDesigner: React.FC = ({ const ruleToSave = { ...currentRule, scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지 - tableName: currentTableName || currentRule.tableName || "", // 현재 테이블명 자동 설정 + tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 자동 설정 (빈 값은 null) menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용) }; diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4c3e6506..8e1f1ce3 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -433,7 +433,10 @@ export const InteractiveScreenViewer: React.FC = ( return (
- +
); } diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index d1cd2a5f..32591d95 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -39,6 +39,7 @@ interface InteractiveScreenViewerProps { id: number; tableName?: string; }; + menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용) onSave?: () => Promise; onRefresh?: () => void; onFlowRefresh?: () => void; @@ -57,6 +58,7 @@ export const InteractiveScreenViewerDynamic: React.FC void; + menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요) } /** @@ -27,7 +28,15 @@ export function DataFilterConfigPanel({ columns = [], config, onConfigChange, + menuObjid, // 🆕 메뉴 OBJID }: DataFilterConfigPanelProps) { + console.log("🔍 [DataFilterConfigPanel] 초기화:", { + tableName, + columnsCount: columns.length, + menuObjid, + sampleColumns: columns.slice(0, 3), + }); + const [localConfig, setLocalConfig] = useState( config || { enabled: false, @@ -43,6 +52,14 @@ export function DataFilterConfigPanel({ useEffect(() => { if (config) { setLocalConfig(config); + + // 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드 + config.filters?.forEach((filter) => { + if (filter.valueType === "category" && filter.columnName) { + console.log("🔄 기존 카테고리 필터 감지, 값 로딩:", filter.columnName); + loadCategoryValues(filter.columnName); + } + }); } }, [config]); @@ -55,20 +72,34 @@ export function DataFilterConfigPanel({ setLoadingCategories(prev => ({ ...prev, [columnName]: true })); try { - const response = await apiClient.get( - `/table-categories/${tableName}/${columnName}/values` + console.log("🔍 카테고리 값 로드 시작:", { + tableName, + columnName, + menuObjid, + }); + + const response = await getCategoryValues( + tableName, + columnName, + false, // includeInactive + menuObjid // 🆕 메뉴 OBJID 전달 ); - if (response.data.success && response.data.data) { - const values = response.data.data.map((item: any) => ({ + console.log("📦 카테고리 값 로드 응답:", response); + + if (response.success && response.data) { + const values = response.data.map((item: any) => ({ value: item.valueCode, label: item.valueLabel, })); + console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length }); setCategoryValues(prev => ({ ...prev, [columnName]: values })); + } else { + console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response); } } catch (error) { - console.error(`카테고리 값 로드 실패 (${columnName}):`, error); + console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error); } finally { setLoadingCategories(prev => ({ ...prev, [columnName]: false })); } diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 683017cf..73b53783 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -11,9 +11,10 @@ interface TabsWidgetProps { component: TabsComponent; className?: string; style?: React.CSSProperties; + menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID } -export function TabsWidget({ component, className, style }: TabsWidgetProps) { +export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) { const { tabs = [], defaultTab, @@ -233,6 +234,11 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) { key={component.id} component={component} allComponents={components} + screenInfo={{ + id: tab.screenId, + tableName: layoutData.tableName, + }} + menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달 /> ))}
diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index 80fb210a..8aba0a1b 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -50,6 +50,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(config.fieldGroups || []); + // 🆕 표시 항목의 입력값을 위한 로컬 상태 (포커스 유지용) + const [localDisplayItemInputs, setLocalDisplayItemInputs] = useState>>({}); + // 🆕 그룹별 펼침/접힘 상태 const [expandedGroups, setExpandedGroups] = useState>({}); @@ -140,6 +143,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + setLocalFieldGroups(config.fieldGroups || []); + // 로컬 입력 상태는 기존 값 보존 (사용자가 입력 중인 값 유지) + }, [config.fieldGroups]); + // 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드 useEffect(() => { if (!localFields || localFields.length === 0) return; @@ -1177,8 +1186,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { value: e.target.value })} + value={ + localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined + ? localDisplayItemInputs[group.id][itemIndex].value + : item.value || "" + } + onChange={(e) => { + const newValue = e.target.value; + // 로컬 상태 즉시 업데이트 (포커스 유지) + setLocalDisplayItemInputs(prev => ({ + ...prev, + [group.id]: { + ...prev[group.id], + [itemIndex]: { + ...prev[group.id]?.[itemIndex], + value: newValue + } + } + })); + // 실제 상태 업데이트 + updateDisplayItemInGroup(group.id, itemIndex, { value: newValue }); + }} placeholder="| , / , -" className="h-6 text-[9px] sm:text-[10px]" /> @@ -1206,8 +1234,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { label: e.target.value })} + value={ + localDisplayItemInputs[group.id]?.[itemIndex]?.label !== undefined + ? localDisplayItemInputs[group.id][itemIndex].label + : item.label || "" + } + onChange={(e) => { + const newValue = e.target.value; + // 로컬 상태 즉시 업데이트 (포커스 유지) + setLocalDisplayItemInputs(prev => ({ + ...prev, + [group.id]: { + ...prev[group.id], + [itemIndex]: { + ...prev[group.id]?.[itemIndex], + label: newValue + } + } + })); + // 실제 상태 업데이트 + updateDisplayItemInGroup(group.id, itemIndex, { label: newValue }); + }} placeholder="라벨 (예: 거래처:)" className="h-6 w-full text-[9px] sm:text-[10px]" /> diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 9f88e290..387ef85f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -23,6 +23,7 @@ interface SplitPanelLayoutConfigPanelProps { onChange: (config: SplitPanelLayoutConfig) => void; tables?: TableInfo[]; // 전체 테이블 목록 (선택적) screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용) + menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요) } /** @@ -201,6 +202,7 @@ export const SplitPanelLayoutConfigPanel: React.FC { const [rightTableOpen, setRightTableOpen] = useState(false); const [leftColumnOpen, setLeftColumnOpen] = useState(false); @@ -211,9 +213,26 @@ export const SplitPanelLayoutConfigPanel: React.FC>({}); + + // 🆕 입력 필드용 로컬 상태 + const [isUserEditing, setIsUserEditing] = useState(false); + const [localTitles, setLocalTitles] = useState({ + left: config.leftPanel?.title || "", + right: config.rightPanel?.title || "", + }); // 관계 타입 const relationshipType = config.rightPanel?.relation?.type || "detail"; + + // config 변경 시 로컬 타이틀 동기화 (사용자가 입력 중이 아닐 때만) + useEffect(() => { + if (!isUserEditing) { + setLocalTitles({ + left: config.leftPanel?.title || "", + right: config.rightPanel?.title || "", + }); + } + }, [config.leftPanel?.title, config.rightPanel?.title, isUserEditing]); // 조인 모드일 때만 전체 테이블 목록 로드 useEffect(() => { @@ -568,8 +587,15 @@ export const SplitPanelLayoutConfigPanel: React.FC updateLeftPanel({ title: e.target.value })} + value={localTitles.left} + onChange={(e) => { + setIsUserEditing(true); + setLocalTitles(prev => ({ ...prev, left: e.target.value })); + }} + onBlur={() => { + setIsUserEditing(false); + updateLeftPanel({ title: localTitles.left }); + }} placeholder="좌측 패널 제목" /> @@ -1345,6 +1371,7 @@ export const SplitPanelLayoutConfigPanel: React.FC updateLeftPanel({ dataFilter })} + menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 /> @@ -1355,8 +1382,15 @@ export const SplitPanelLayoutConfigPanel: React.FC updateRightPanel({ title: e.target.value })} + value={localTitles.right} + onChange={(e) => { + setIsUserEditing(true); + setLocalTitles(prev => ({ ...prev, right: e.target.value })); + }} + onBlur={() => { + setIsUserEditing(false); + updateRightPanel({ title: localTitles.right }); + }} placeholder="우측 패널 제목" /> @@ -2270,6 +2304,7 @@ export const SplitPanelLayoutConfigPanel: React.FC updateRightPanel({ dataFilter })} + menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 /> diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index c5ed9aaa..0f13abf8 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -255,7 +255,8 @@ export const TableListConfigPanel: React.FC = ({ }, [config.columns]); const handleChange = (key: keyof TableListConfig, value: any) => { - onChange({ [key]: value }); + // 기존 config와 병합하여 전달 (다른 속성 손실 방지) + onChange({ ...config, [key]: value }); }; const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => { From 6669a3fc5e8871e977b1642646dd22f4850c44fe Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 25 Nov 2025 16:13:31 +0900 Subject: [PATCH 03/32] =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/ui/resizable-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/ui/resizable-dialog.tsx b/frontend/components/ui/resizable-dialog.tsx index 74a53411..604bca3d 100644 --- a/frontend/components/ui/resizable-dialog.tsx +++ b/frontend/components/ui/resizable-dialog.tsx @@ -452,7 +452,7 @@ const ResizableDialogContent = React.forwardRef<
{children}
From 8fdf57bedd891b6f545ab938d3163f878bfbcddc Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 25 Nov 2025 16:56:50 +0900 Subject: [PATCH 04/32] =?UTF-8?q?chore:=20=EA=B3=BC=EB=8F=84=ED=95=9C=20?= =?UTF-8?q?=EC=BD=98=EC=86=94=20=EB=A1=9C=EA=B7=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModalRepeaterTableComponent: 반복 렌더링 로그 제거 - TableListComponent: 렌더링 조건 체크 IIFE 단순화 - ConditionalContainerComponent: 디버깅 로그 삭제 - DynamicComponentRenderer: value 설정 로그 제거 - resizable-dialog: userStyle 상세 로그 정리 - page.tsx: 반복 데이터 탐색 로그 삭제 에러 핸들링 및 주요 분기점 로그만 보존 --- .../app/(main)/screens/[screenId]/page.tsx | 46 +------------ frontend/components/ui/resizable-dialog.tsx | 22 ------- .../lib/registry/DynamicComponentRenderer.tsx | 11 +--- .../ConditionalContainerComponent.tsx | 23 ------- .../ModalRepeaterTableComponent.tsx | 65 +++---------------- .../table-list/TableListComponent.tsx | 16 +---- 6 files changed, 14 insertions(+), 169 deletions(-) diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index ce99a685..5685d23a 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -356,17 +356,6 @@ function ScreenViewPage() { return isButton; }); - console.log( - "🔍 메뉴에서 발견된 전체 버튼:", - allButtons.map((b) => ({ - id: b.id, - label: b.label, - positionX: b.position.x, - positionY: b.position.y, - width: b.size?.width, - height: b.size?.height, - })), - ); topLevelComponents.forEach((component) => { const isButton = @@ -406,33 +395,13 @@ function ScreenViewPage() { (c) => (c as any).componentId === "table-search-widget", ); - // 디버그: 모든 컴포넌트 타입 확인 - console.log( - "🔍 전체 컴포넌트 타입:", - regularComponents.map((c) => ({ - id: c.id, - type: c.type, - componentType: (c as any).componentType, - componentId: (c as any).componentId, - })), - ); - - // 🆕 조건부 컨테이너들을 찾기 + // 조건부 컨테이너들을 찾기 const conditionalContainers = regularComponents.filter( (c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container", ); - console.log( - "🔍 조건부 컨테이너 발견:", - conditionalContainers.map((c) => ({ - id: c.id, - y: c.position.y, - size: c.size, - })), - ); - // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 const adjustedComponents = regularComponents.map((component) => { const isTableSearchWidget = (component as any).componentId === "table-search-widget"; @@ -520,12 +489,6 @@ function ScreenViewPage() { columnOrder={tableColumnOrder} tableDisplayData={tableDisplayData} onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - console.log("🔍 화면에서 선택된 행 데이터:", selectedData); - console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터:", { - count: tableDisplayData?.length, - firstRow: tableDisplayData?.[0], - }); setSelectedRowsData(selectedData); setTableSortBy(sortBy); setTableSortOrder(sortOrder || "asc"); @@ -604,12 +567,6 @@ function ScreenViewPage() { columnOrder, tableDisplayData, ) => { - console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); - console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터 (자식):", { - count: tableDisplayData?.length, - firstRow: tableDisplayData?.[0], - }); setSelectedRowsData(selectedData); setTableSortBy(sortBy); setTableSortOrder(sortOrder || "asc"); @@ -618,7 +575,6 @@ function ScreenViewPage() { }} refreshKey={tableRefreshKey} onRefresh={() => { - console.log("🔄 테이블 새로고침 요청됨 (자식)"); setTableRefreshKey((prev) => prev + 1); setSelectedRowsData([]); // 선택 해제 }} diff --git a/frontend/components/ui/resizable-dialog.tsx b/frontend/components/ui/resizable-dialog.tsx index 74a53411..cc28be85 100644 --- a/frontend/components/ui/resizable-dialog.tsx +++ b/frontend/components/ui/resizable-dialog.tsx @@ -122,10 +122,6 @@ const ResizableDialogContent = React.forwardRef< // 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용) if (userStyle) { - console.log("🔍 userStyle 감지:", userStyle); - console.log("🔍 userStyle.width 타입:", typeof userStyle.width, "값:", userStyle.width); - console.log("🔍 userStyle.height 타입:", typeof userStyle.height, "값:", userStyle.height); - const styleWidth = typeof userStyle.width === 'string' ? parseInt(userStyle.width) : userStyle.width; @@ -133,31 +129,15 @@ const ResizableDialogContent = React.forwardRef< ? parseInt(userStyle.height) : userStyle.height; - console.log("📏 파싱된 크기:", { - styleWidth, - styleHeight, - "styleWidth truthy?": !!styleWidth, - "styleHeight truthy?": !!styleHeight, - minWidth, - maxWidth, - minHeight, - maxHeight - }); - if (styleWidth && styleHeight) { const finalSize = { width: Math.max(minWidth, Math.min(maxWidth, styleWidth)), height: Math.max(minHeight, Math.min(maxHeight, styleHeight)), }; - console.log("✅ userStyle 크기 사용:", finalSize); return finalSize; - } else { - console.log("❌ styleWidth 또는 styleHeight가 falsy:", { styleWidth, styleHeight }); } } - console.log("⚠️ userStyle 없음, defaultWidth/defaultHeight 사용:", { defaultWidth, defaultHeight }); - // 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지) // if (contentRef.current) { // const rect = contentRef.current.getBoundingClientRect(); @@ -209,7 +189,6 @@ const ResizableDialogContent = React.forwardRef< // 사용자가 리사이징한 크기 우선 setSize({ width: savedSize.width, height: savedSize.height }); setUserResized(true); - console.log("✅ 사용자 리사이징 크기 적용:", savedSize); } else if (userStyle && userStyle.width && userStyle.height) { // 화면관리에서 설정한 크기 const styleWidth = typeof userStyle.width === 'string' @@ -224,7 +203,6 @@ const ResizableDialogContent = React.forwardRef< width: Math.max(minWidth, Math.min(maxWidth, styleWidth)), height: Math.max(minHeight, Math.min(maxHeight, styleHeight)), }; - console.log("🔄 userStyle 크기 적용:", newSize); setSize(newSize); } } diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index cf6037eb..245e2527 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -289,17 +289,8 @@ export const DynamicComponentRenderer: React.FC = // modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화 let currentValue; if (componentType === "modal-repeater-table") { - // 🆕 EditModal에서 전달된 groupedData가 있으면 우선 사용 + // EditModal에서 전달된 groupedData가 있으면 우선 사용 currentValue = props.groupedData || formData?.[fieldName] || []; - - // 디버깅 로그 - console.log("🔍 [DynamicComponentRenderer] ModalRepeaterTable value 설정:", { - hasGroupedData: !!props.groupedData, - groupedDataLength: props.groupedData?.length || 0, - fieldName, - formDataValue: formData?.[fieldName], - finalValueLength: Array.isArray(currentValue) ? currentValue.length : 0, - }); } else { currentValue = formData?.[fieldName] || ""; } diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index 626ee137..6f2ab183 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -13,8 +13,6 @@ import { ConditionalContainerProps, ConditionalSection } from "./types"; import { ConditionalSectionViewer } from "./ConditionalSectionViewer"; import { cn } from "@/lib/utils"; -console.log("🚀 ConditionalContainerComponent 모듈 로드됨!"); - /** * 조건부 컨테이너 컴포넌트 * 상단 셀렉트박스 값에 따라 하단에 다른 UI를 표시 @@ -43,11 +41,6 @@ export function ConditionalContainerComponent({ groupedData, // 🆕 그룹 데이터 onSave, // 🆕 EditModal의 handleSave 콜백 }: ConditionalContainerProps) { - console.log("🎯 ConditionalContainerComponent 렌더링!", { - isDesignMode, - hasOnHeightChange: !!onHeightChange, - componentId, - }); // config prop 우선, 없으면 개별 prop 사용 const controlField = config?.controlField || propControlField || "condition"; @@ -86,24 +79,8 @@ export function ConditionalContainerComponent({ const containerRef = useRef(null); const previousHeightRef = useRef(0); - // 🔍 디버그: props 확인 - useEffect(() => { - console.log("🔍 ConditionalContainer props:", { - isDesignMode, - hasOnHeightChange: !!onHeightChange, - componentId, - selectedValue, - }); - }, [isDesignMode, onHeightChange, componentId, selectedValue]); - // 높이 변화 감지 및 콜백 호출 useEffect(() => { - console.log("🔍 ResizeObserver 등록 조건:", { - hasContainer: !!containerRef.current, - isDesignMode, - hasOnHeightChange: !!onHeightChange, - }); - if (!containerRef.current || isDesignMode || !onHeightChange) return; const resizeObserver = new ResizeObserver((entries) => { diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 59ce35a8..91d7b36f 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -224,13 +224,11 @@ export function ModalRepeaterTableComponent({ const configuredColumns = componentConfig?.columns || propColumns || []; if (configuredColumns.length > 0) { - console.log("✅ 설정된 columns 사용:", configuredColumns); return configuredColumns; } // columns가 비어있으면 sourceColumns로부터 자동 생성 if (sourceColumns.length > 0) { - console.log("🔄 sourceColumns로부터 자동 생성:", sourceColumns); const autoColumns: RepeaterColumnConfig[] = sourceColumns.map((field) => ({ field: field, label: field, // 필드명을 라벨로 사용 (나중에 설정에서 변경 가능) @@ -238,85 +236,49 @@ export function ModalRepeaterTableComponent({ type: "text" as const, width: "150px", })); - console.log("📋 자동 생성된 columns:", autoColumns); return autoColumns; } - console.warn("⚠️ columns와 sourceColumns 모두 비어있음!"); + console.warn("⚠️ [ModalRepeaterTable] columns와 sourceColumns 모두 비어있음!"); return []; }, [componentConfig?.columns, propColumns, sourceColumns]); - // 초기 props 로깅 + // 초기 props 검증 useEffect(() => { if (rawSourceColumns.length !== sourceColumns.length) { - console.warn(`⚠️ sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}개 (빈 문자열 제거)`); + console.warn(`⚠️ [ModalRepeaterTable] sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}개`); } if (rawUniqueField !== uniqueField) { - console.warn(`⚠️ uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`); + console.warn(`⚠️ [ModalRepeaterTable] uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`); } - console.log("🎬 ModalRepeaterTableComponent 마운트:", { - columnsLength: columns.length, - sourceTable, - sourceColumns, - uniqueField, - }); - if (columns.length === 0) { - console.error("❌ columns가 비어있습니다! sourceColumns:", sourceColumns); - } else { - console.log("✅ columns 설정 완료:", columns.map(c => c.label || c.field).join(", ")); + console.error("❌ [ModalRepeaterTable] columns가 비어있습니다!", { sourceColumns }); } }, []); - // value 변경 감지 - useEffect(() => { - console.log("📦 ModalRepeaterTableComponent value 변경:", { - valueLength: value.length, - }); - }, [value]); - // 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너) useEffect(() => { const handleSaveRequest = async (event: Event) => { const componentKey = columnName || component?.id || "modal_repeater_data"; - console.log("🔔 [ModalRepeaterTable] beforeFormSave 이벤트 수신!", { - componentKey, - itemsCount: value.length, - hasOnFormDataChange: !!onFormDataChange, - columnName, - componentId: component?.id, - targetTable, - }); - if (value.length === 0) { console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음"); return; } - // 🔥 sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거) - console.log("🔍 [ModalRepeaterTable] 필터링 전 데이터:", { - sourceColumns, - sourceTable, - targetTable, - sampleItem: value[0], - itemKeys: value[0] ? Object.keys(value[0]) : [], - }); - + // sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거) const filteredData = value.map((item: any) => { const filtered: Record = {}; Object.keys(item).forEach((key) => { // sourceColumns에 포함된 컬럼은 제외 (item_info 테이블의 컬럼) if (sourceColumns.includes(key)) { - console.log(` ⛔ ${key} 제외 (sourceColumn)`); return; } // 메타데이터 필드도 제외 if (key.startsWith("_")) { - console.log(` ⛔ ${key} 제외 (메타데이터)`); return; } filtered[key] = item[key]; @@ -325,12 +287,7 @@ export function ModalRepeaterTableComponent({ return filtered; }); - console.log("✅ [ModalRepeaterTable] 필터링 후 데이터:", { - filteredItemKeys: filteredData[0] ? Object.keys(filteredData[0]) : [], - sampleFilteredItem: filteredData[0], - }); - - // 🔥 targetTable 메타데이터를 배열 항목에 추가 + // targetTable 메타데이터를 배열 항목에 추가 const dataWithTargetTable = targetTable ? filteredData.map((item: any) => ({ ...item, @@ -338,21 +295,19 @@ export function ModalRepeaterTableComponent({ })) : filteredData; - // ✅ CustomEvent의 detail에 데이터 추가 + // CustomEvent의 detail에 데이터 추가 if (event instanceof CustomEvent && event.detail) { event.detail.formData[componentKey] = dataWithTargetTable; - console.log("✅ [ModalRepeaterTable] context.formData에 데이터 추가 완료:", { + console.log("✅ [ModalRepeaterTable] 저장 데이터 준비:", { key: componentKey, itemCount: dataWithTargetTable.length, - targetTable: targetTable || "미설정 (화면 설계에서 설정 필요)", - sampleItem: dataWithTargetTable[0], + targetTable: targetTable || "미설정", }); } // 기존 onFormDataChange도 호출 (호환성) if (onFormDataChange) { onFormDataChange(componentKey, dataWithTargetTable); - console.log("✅ [ModalRepeaterTable] onFormDataChange 호출 완료"); } }; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index a8356721..76556ecb 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -2404,18 +2404,9 @@ export const TableListComponent: React.FC = ({ - ) : (() => { - console.log("🔍 [TableList] 렌더링 조건 체크", { - groupByColumns: groupByColumns.length, - groupedDataLength: groupedData.length, - willRenderGrouped: groupByColumns.length > 0 && groupedData.length > 0, - dataLength: data.length, - }); - return groupByColumns.length > 0 && groupedData.length > 0; - })() ? ( + ) : groupByColumns.length > 0 && groupedData.length > 0 ? ( // 그룹화된 렌더링 groupedData.map((group) => { - console.log("📊 [TableList] 그룹 렌더링:", group.groupKey, group.count); const isCollapsed = collapsedGroups.has(group.groupKey); return ( @@ -2508,10 +2499,7 @@ export const TableListComponent: React.FC = ({ }) ) : ( // 일반 렌더링 (그룹 없음) - (() => { - console.log("📋 [TableList] 일반 렌더링 시작:", data.length, "개 행"); - return data; - })().map((row, index) => ( + data.map((row, index) => ( Date: Tue, 25 Nov 2025 17:08:12 +0900 Subject: [PATCH 05/32] =?UTF-8?q?STP=20=EC=A0=95=EC=B0=A8=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=EB=A5=BC=20=EC=9E=90=EC=9E=AC=20=EB=AF=B8?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=20=EC=98=81=EC=97=AD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=ED=99=94=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 53 ++++++------- .../widgets/yard-3d/DigitalTwinViewer.tsx | 24 +++--- .../widgets/yard-3d/Yard3DCanvas.tsx | 78 ++++++++++--------- 3 files changed, 82 insertions(+), 73 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 9a71c338..f3d826b5 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check } from "lucide-react"; +import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -550,10 +550,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi areaKey: obj.area_key, locaKey: obj.loca_key, locType: obj.loc_type, - materialCount: obj.material_count, - materialPreview: obj.material_preview_height - ? { height: parseFloat(obj.material_preview_height) } - : undefined, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, parentId: obj.parent_id, displayOrder: obj.display_order, locked: obj.locked, @@ -761,12 +762,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 기본 크기 설정 let objectSize = defaults.size || { x: 5, y: 5, z: 5 }; - // Location 배치 시 자재 개수에 따라 높이 자동 설정 + // Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재) if ( - (draggedTool === "location-bed" || - draggedTool === "location-stp" || - draggedTool === "location-temp" || - draggedTool === "location-dest") && + (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") && locaKey && selectedDbConnection && hierarchyConfig?.material @@ -877,12 +875,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi setDraggedAreaData(null); setDraggedLocationData(null); - // Location 배치 시 자재 개수 로드 + // Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재) if ( - (draggedTool === "location-bed" || - draggedTool === "location-stp" || - draggedTool === "location-temp" || - draggedTool === "location-dest") && + (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") && locaKey ) { // 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행) @@ -965,13 +960,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi loadLocationsForArea(obj.areaKey); setShowMaterialPanel(false); } - // Location을 클릭한 경우, 해당 Location의 자재 목록 로드 + // Location을 클릭한 경우, 해당 Location의 자재 목록 로드 (STP는 자재 미적재이므로 제외) else if ( obj && - (obj.type === "location-bed" || - obj.type === "location-stp" || - obj.type === "location-temp" || - obj.type === "location-dest") && + (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && obj.locaKey && selectedDbConnection ) { @@ -988,9 +980,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys); if (response.success && response.data) { - // 각 Location 객체에 자재 개수 업데이트 + // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) setPlacedObjects((prev) => prev.map((obj) => { + if ( + !obj.locaKey || + obj.type === "location-stp" // STP는 자재 없음 + ) { + return obj; + } const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey); if (materialCount) { return { @@ -1278,7 +1276,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const oldSize = actualObject.size; const newSize = { ...oldSize, ...updates.size }; - // W, D를 5 단위로 스냅 + // W, D를 5 단위로 스냅 (STP 포함) newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5); newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5); @@ -1391,10 +1389,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi areaKey: obj.area_key, locaKey: obj.loca_key, locType: obj.loc_type, - materialCount: obj.material_count, - materialPreview: obj.material_preview_height - ? { height: parseFloat(obj.material_preview_height) } - : undefined, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, parentId: obj.parent_id, displayOrder: obj.display_order, locked: obj.locked, @@ -1798,6 +1797,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi {isLocationPlaced ? ( + ) : locationType === "location-stp" ? ( + ) : ( )} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index cc34fb19..1dfe8251 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Loader2, Search, X, Grid3x3, Package } from "lucide-react"; +import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -87,10 +87,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) areaKey: obj.area_key, locaKey: obj.loca_key, locType: obj.loc_type, - materialCount: obj.material_count, - materialPreview: obj.material_preview_height - ? { height: parseFloat(obj.material_preview_height) } - : undefined, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, parentId: obj.parent_id, displayOrder: obj.display_order, locked: obj.locked, @@ -166,13 +167,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) const obj = placedObjects.find((o) => o.id === objectId); setSelectedObject(obj || null); - // Location을 클릭한 경우, 자재 정보 표시 + // Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외) if ( obj && - (obj.type === "location-bed" || - obj.type === "location-stp" || - obj.type === "location-temp" || - obj.type === "location-dest") && + (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && obj.locaKey && externalDbConnectionId ) { @@ -471,7 +469,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) >
- + {locationObj.type === "location-stp" ? ( + + ) : ( + + )} {locationObj.name}
- - - + // 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역) + { + const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형) + const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15; + const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3; - {/* Location 이름 */} - {placement.name && ( + return ( + <> + {/* 타원형 플랫폼: 단위 실린더를 W/D로 스케일 */} + + + + + + {/* 상단 'P' 마크 (주차 아이콘 역할) */} - {placement.name} + P - )} - {/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */} - {placement.material_count !== undefined && placement.material_count > 0 && ( - - {`자재: ${placement.material_count}개`} - - )} - - ); + {/* Location 이름 */} + {placement.name && ( + + {placement.name} + + )} + + ); + } // case "gantry-crane": // // 겐트리 크레인: 기둥 2개 + 상단 빔 From f0513e20d8de18e895c462961cd8eace15837c0e Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 25 Nov 2025 17:19:39 +0900 Subject: [PATCH 06/32] =?UTF-8?q?3D=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=9E=85=EB=A0=A5=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 110 +++++++++++++++--- 1 file changed, 91 insertions(+), 19 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index f3d826b5..3a4b1901 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -39,6 +39,77 @@ import { DialogTitle, } from "@/components/ui/dialog"; +// 성능 최적화를 위한 디바운스/Blur 처리된 Input 컴포넌트 +const DebouncedInput = ({ + value, + onChange, + onCommit, + type = "text", + debounce = 0, + ...props +}: React.InputHTMLAttributes & { + onCommit?: (value: any) => void; + debounce?: number; +}) => { + const [localValue, setLocalValue] = useState(value); + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + if (!isEditing) { + setLocalValue(value); + } + }, [value, isEditing]); + + // 색상 입력 등을 위한 디바운스 커밋 + useEffect(() => { + if (debounce > 0 && isEditing && onCommit) { + const timer = setTimeout(() => { + onCommit(type === "number" ? parseFloat(localValue as string) : localValue); + }, debounce); + return () => clearTimeout(timer); + } + }, [localValue, debounce, isEditing, onCommit, type]); + + const handleChange = (e: React.ChangeEvent) => { + setLocalValue(e.target.value); + if (onChange) onChange(e); + }; + + const handleBlur = (e: React.FocusEvent) => { + setIsEditing(false); + if (onCommit && debounce === 0) { + // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, + // 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨) + onCommit(type === "number" ? parseFloat(localValue as string) : localValue); + } + if (props.onBlur) props.onBlur(e); + }; + + const handleFocus = (e: React.FocusEvent) => { + setIsEditing(true); + if (props.onFocus) props.onFocus(e); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + if (props.onKeyDown) props.onKeyDown(e); + }; + + return ( + + ); +}; + // 백엔드 DB 객체 타입 (snake_case) interface DbObject { id: number; @@ -2070,10 +2141,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - handleObjectUpdate({ name: e.target.value })} + onCommit={(val) => handleObjectUpdate({ name: val })} className="mt-1.5 h-9 text-sm" />
@@ -2086,15 +2157,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ position: { ...selectedObject.position, - x: parseFloat(e.target.value), + x: val, }, }) } @@ -2105,15 +2176,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ position: { ...selectedObject.position, - z: parseFloat(e.target.value), + z: val, }, }) } @@ -2131,17 +2202,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ size: { ...selectedObject.size, - x: parseFloat(e.target.value), + x: val, }, }) } @@ -2152,15 +2223,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ size: { ...selectedObject.size, - y: parseFloat(e.target.value), + y: val, }, }) } @@ -2171,17 +2242,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + onCommit={(val) => handleObjectUpdate({ size: { ...selectedObject.size, - z: parseFloat(e.target.value), + z: val, }, }) } @@ -2196,11 +2267,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - handleObjectUpdate({ color: e.target.value })} + onCommit={(val) => handleObjectUpdate({ color: val })} className="mt-1.5 h-9" /> From b2afe8674ea84b9cf83852ebb64bf20b9b676c8b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 25 Nov 2025 17:23:24 +0900 Subject: [PATCH 07/32] =?UTF-8?q?=203D=20=EB=B7=B0=EC=96=B4=20=EC=A1=B0?= =?UTF-8?q?=EB=AA=85=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0=20(?= =?UTF-8?q?=EC=83=89=EC=83=81=20=EC=99=9C=EA=B3=A1=20=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 3dd210b1..892acc88 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -1104,10 +1104,12 @@ function Scene({ orbitControlsRef={orbitControlsRef} /> - {/* 조명 */} - - - + {/* 조명 - 전체적으로 밝게 조정 */} + + + + + {/* 배경색 */} From 0a6c5fbfcc253fac125f875725fb61dfbde0918f Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 25 Nov 2025 17:32:52 +0900 Subject: [PATCH 08/32] =?UTF-8?q?fix:=20=EC=88=98=EC=A3=BC=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=82=A9=EA=B8=B0=EC=9D=BC=20DATE=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프론트엔드: EditModal에 날짜 정규화 함수 추가 (YYYY-MM-DD 형식) - 백엔드: convertValueForPostgreSQL에서 DATE 타입 문자열 그대로 유지 - 기존 TIMESTAMP 형식 변환을 DATE 타입 문자열 유지로 변경 - 날짜 변환 로직에서 YYYY-MM-DD 형식 문자열 변환 제거 closes #납기일-TIMESTAMP-형식-저장-이슈 --- .../src/services/dynamicFormService.ts | 22 +++-- frontend/components/screen/EditModal.tsx | 87 +++++++++++++++---- 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e9485620..1ed28140 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -99,10 +99,18 @@ export class DynamicFormService { } try { - // YYYY-MM-DD 형식인 경우 시간 추가해서 Date 객체 생성 + // YYYY-MM-DD 형식인 경우 if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { - console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`); - return new Date(value + "T00:00:00"); + // DATE 타입이면 문자열 그대로 유지 + if (lowerDataType === "date") { + console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`); + return value; // 문자열 그대로 반환 + } + // TIMESTAMP 타입이면 Date 객체로 변환 + else { + console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`); + return new Date(value + "T00:00:00"); + } } // 다른 날짜 형식도 Date 객체로 변환 else { @@ -300,13 +308,13 @@ export class DynamicFormService { ) { // YYYY-MM-DD HH:mm:ss 형태의 문자열을 Date 객체로 변환 if (value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) { - console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`); + console.log(`📅 날짜시간 변환: ${key} = "${value}" -> Date 객체`); dataToInsert[key] = new Date(value); } - // YYYY-MM-DD 형태의 문자열을 Date 객체로 변환 + // YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장) else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) { - console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`); - dataToInsert[key] = new Date(value + "T00:00:00"); + console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`); + // dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식) } } }); diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index f9b803b2..e44eef4e 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -316,6 +316,33 @@ export const EditModal: React.FC = ({ className }) => { screenId: modalState.screenId, }); + // 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환) + const normalizeDateField = (value: any): string | null => { + if (!value) return null; + + // ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체 + if (value instanceof Date || typeof value === "string") { + try { + const date = new Date(value); + if (isNaN(date.getTime())) return null; + + // YYYY-MM-DD 형식으로 변환 + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } catch (error) { + console.warn("날짜 변환 실패:", value, error); + return null; + } + } + + return null; + }; + + // 날짜 필드 목록 + const dateFields = ["item_due_date", "delivery_date", "due_date", "order_date"]; + let insertedCount = 0; let updatedCount = 0; let deletedCount = 0; @@ -333,6 +360,17 @@ export const EditModal: React.FC = ({ className }) => { delete insertData.id; // id는 자동 생성되므로 제거 + // 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환) + dateFields.forEach((fieldName) => { + if (insertData[fieldName]) { + const normalizedDate = normalizeDateField(insertData[fieldName]); + if (normalizedDate) { + insertData[fieldName] = normalizedDate; + console.log(`📅 [날짜 정규화] ${fieldName}: ${currentData[fieldName]} → ${normalizedDate}`); + } + } + }); + // 🆕 groupByColumns의 값을 강제로 포함 (order_no 등) if (modalState.groupByColumns && modalState.groupByColumns.length > 0) { modalState.groupByColumns.forEach((colName) => { @@ -348,23 +386,32 @@ export const EditModal: React.FC = ({ className }) => { // 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등) // formData에서 품목별 필드가 아닌 공통 필드를 복사 const commonFields = [ - 'partner_id', // 거래처 - 'manager_id', // 담당자 - 'delivery_partner_id', // 납품처 - 'delivery_address', // 납품장소 - 'memo', // 메모 - 'order_date', // 주문일 - 'due_date', // 납기일 - 'shipping_method', // 배송방법 - 'status', // 상태 - 'sales_type', // 영업유형 + "partner_id", // 거래처 + "manager_id", // 담당자 + "delivery_partner_id", // 납품처 + "delivery_address", // 납품장소 + "memo", // 메모 + "order_date", // 주문일 + "due_date", // 납기일 + "shipping_method", // 배송방법 + "status", // 상태 + "sales_type", // 영업유형 ]; commonFields.forEach((fieldName) => { // formData에 값이 있으면 추가 if (formData[fieldName] !== undefined && formData[fieldName] !== null) { - insertData[fieldName] = formData[fieldName]; - console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]); + // 날짜 필드인 경우 정규화 + if (dateFields.includes(fieldName)) { + const normalizedDate = normalizeDateField(formData[fieldName]); + if (normalizedDate) { + insertData[fieldName] = normalizedDate; + console.log(`🔗 [공통 필드 - 날짜] ${fieldName} 값 추가:`, normalizedDate); + } + } else { + insertData[fieldName] = formData[fieldName]; + console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]); + } } }); @@ -404,8 +451,15 @@ export const EditModal: React.FC = ({ className }) => { } // 🆕 값 정규화 함수 (타입 통일) - const normalizeValue = (val: any): any => { + const normalizeValue = (val: any, fieldName?: string): any => { if (val === null || val === undefined || val === "") return null; + + // 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화 + if (fieldName && dateFields.includes(fieldName)) { + const normalizedDate = normalizeDateField(val); + return normalizedDate; + } + if (typeof val === "string" && !isNaN(Number(val))) { // 숫자로 변환 가능한 문자열은 숫자로 return Number(val); @@ -422,13 +476,14 @@ export const EditModal: React.FC = ({ className }) => { } // 🆕 타입 정규화 후 비교 - const currentValue = normalizeValue(currentData[key]); - const originalValue = normalizeValue(originalItemData[key]); + const currentValue = normalizeValue(currentData[key], key); + const originalValue = normalizeValue(originalItemData[key], key); // 값이 변경된 경우만 포함 if (currentValue !== originalValue) { console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue} → ${currentValue}`); - changedData[key] = currentData[key]; // 원본 값 사용 (문자열 그대로) + // 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용 + changedData[key] = dateFields.includes(key) ? currentValue : currentData[key]; } }); From ea88cfd0435ccf26461eda738ae34c3685cc3f1b Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 17:48:23 +0900 Subject: [PATCH 09/32] =?UTF-8?q?feat:=20=EB=82=A0=EC=A7=9C=20=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModernDatePicker: 로컬 상태 관리로 즉시 검색 방지 - tempValue 상태 추가하여 확인 버튼 클릭 시에만 검색 실행 - 빠른 선택 버튼 추가 (오늘, 이번주, 이번달, 최근 7일, 최근 30일) - TableSearchWidget: ModernDatePicker 통합 - 기본 HTML input[type=date]를 ModernDatePicker로 교체 - 날짜 범위 객체 {from, to}를 파이프 구분 문자열로 변환 - 백엔드 재시작 없이 작동하도록 임시 포맷팅 적용 - tableManagementService: 날짜 범위 검색 로직 개선 - getColumnWebTypeInfo: web_type이 null이면 input_type 폴백 - buildDateRangeCondition: VARCHAR 타입 날짜 컬럼 지원 - 날짜 컬럼을 ::date로 캐스팅하여 타입 호환성 확보 - 파이프 구분 문자열 파싱 지원 (YYYY-MM-DD|YYYY-MM-DD) - 디버깅 로깅 추가 - 컬럼 타입 정보 조회 결과 로깅 - 날짜 범위 검색 조건 생성 과정 추적 --- .../src/services/tableManagementService.ts | 78 +++++++++-- .../screen/filters/ModernDatePicker.tsx | 127 +++++++++++++++--- .../table-search-widget/TableSearchWidget.tsx | 77 ++++++++--- 3 files changed, 241 insertions(+), 41 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 38fc77b1..173de022 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1165,6 +1165,23 @@ export class TableManagementService { paramCount: number; } | null> { try { + // 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!) + if (typeof value === "string" && value.includes("|")) { + const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); + if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + return this.buildDateRangeCondition(columnName, value, paramIndex); + } + } + + // 🔧 날짜 범위 객체 {from, to} 체크 + if (typeof value === "object" && value !== null && ("from" in value || "to" in value)) { + // 날짜 범위 객체는 그대로 전달 + const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); + if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + return this.buildDateRangeCondition(columnName, value, paramIndex); + } + } + // 🔧 {value, operator} 형태의 필터 객체 처리 let actualValue = value; let operator = "contains"; // 기본값 @@ -1193,6 +1210,12 @@ export class TableManagementService { // 컬럼 타입 정보 조회 const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); + logger.info(`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`, + `webType=${columnInfo?.webType || 'NULL'}`, + `inputType=${columnInfo?.inputType || 'NULL'}`, + `actualValue=${JSON.stringify(actualValue)}`, + `operator=${operator}` + ); if (!columnInfo) { // 컬럼 정보가 없으면 operator에 따른 기본 검색 @@ -1292,20 +1315,41 @@ export class TableManagementService { const values: any[] = []; let paramCount = 0; - if (typeof value === "object" && value !== null) { + // 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD") + if (typeof value === "string" && value.includes("|")) { + const [fromStr, toStr] = value.split("|"); + + if (fromStr && fromStr.trim() !== "") { + // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 + conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); + values.push(fromStr.trim()); + paramCount++; + } + if (toStr && toStr.trim() !== "") { + // 종료일은 해당 날짜의 23:59:59까지 포함 + conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); + values.push(toStr.trim()); + paramCount++; + } + } + // 객체 형식의 날짜 범위 ({from, to}) + else if (typeof value === "object" && value !== null) { if (value.from) { - conditions.push(`${columnName} >= $${paramIndex + paramCount}`); + // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 + conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); values.push(value.from); paramCount++; } if (value.to) { - conditions.push(`${columnName} <= $${paramIndex + paramCount}`); + // 종료일은 해당 날짜의 23:59:59까지 포함 + conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); values.push(value.to); paramCount++; } - } else if (typeof value === "string" && value.trim() !== "") { - // 단일 날짜 검색 (해당 날짜의 데이터) - conditions.push(`DATE(${columnName}) = DATE($${paramIndex})`); + } + // 단일 날짜 검색 + else if (typeof value === "string" && value.trim() !== "") { + conditions.push(`${columnName}::date = $${paramIndex}::date`); values.push(value); paramCount = 1; } @@ -1544,6 +1588,7 @@ export class TableManagementService { columnName: string ): Promise<{ webType: string; + inputType?: string; codeCategory?: string; referenceTable?: string; referenceColumn?: string; @@ -1552,29 +1597,44 @@ export class TableManagementService { try { const result = await queryOne<{ web_type: string | null; + input_type: string | null; code_category: string | null; reference_table: string | null; reference_column: string | null; display_column: string | null; }>( - `SELECT web_type, code_category, reference_table, reference_column, display_column + `SELECT web_type, input_type, code_category, reference_table, reference_column, display_column FROM column_labels WHERE table_name = $1 AND column_name = $2 LIMIT 1`, [tableName, columnName] ); + logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, { + found: !!result, + web_type: result?.web_type, + input_type: result?.input_type, + }); + if (!result) { + logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`); return null; } - return { - webType: result.web_type || "", + // web_type이 없으면 input_type을 사용 (레거시 호환) + const webType = result.web_type || result.input_type || ""; + + const columnInfo = { + webType: webType, + inputType: result.input_type || "", codeCategory: result.code_category || undefined, referenceTable: result.reference_table || undefined, referenceColumn: result.reference_column || undefined, displayColumn: result.display_column || undefined, }; + + logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`); + return columnInfo; } catch (error) { logger.error( `컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`, diff --git a/frontend/components/screen/filters/ModernDatePicker.tsx b/frontend/components/screen/filters/ModernDatePicker.tsx index 55a9c64f..0a134927 100644 --- a/frontend/components/screen/filters/ModernDatePicker.tsx +++ b/frontend/components/screen/filters/ModernDatePicker.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; @@ -34,6 +34,17 @@ export const ModernDatePicker: React.FC = ({ label, value const [isOpen, setIsOpen] = useState(false); const [currentMonth, setCurrentMonth] = useState(new Date()); const [selectingType, setSelectingType] = useState<"from" | "to">("from"); + + // 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장) + const [tempValue, setTempValue] = useState(value || {}); + + // 팝오버가 열릴 때 현재 값으로 초기화 + useEffect(() => { + if (isOpen) { + setTempValue(value || {}); + setSelectingType("from"); + } + }, [isOpen, value]); const formatDate = (date: Date | undefined) => { if (!date) return ""; @@ -57,26 +68,91 @@ export const ModernDatePicker: React.FC = ({ label, value }; const handleDateClick = (date: Date) => { + // 로컬 상태만 업데이트 (onChange 호출 안 함) if (selectingType === "from") { - const newValue = { ...value, from: date }; - onChange(newValue); + setTempValue({ ...tempValue, from: date }); setSelectingType("to"); } else { - const newValue = { ...value, to: date }; - onChange(newValue); + setTempValue({ ...tempValue, to: date }); setSelectingType("from"); } }; const handleClear = () => { - onChange({}); + setTempValue({}); setSelectingType("from"); }; const handleConfirm = () => { + // 확인 버튼을 눌렀을 때만 onChange 호출 + onChange(tempValue); + setIsOpen(false); + setSelectingType("from"); + }; + + const handleCancel = () => { + // 취소 시 임시 값 버리고 팝오버 닫기 + setTempValue(value || {}); + setIsOpen(false); + setSelectingType("from"); + }; + + // 빠른 기간 선택 함수들 (즉시 적용 + 팝오버 닫기) + const setToday = () => { + const today = new Date(); + const newValue = { from: today, to: today }; + setTempValue(newValue); + onChange(newValue); + setIsOpen(false); + setSelectingType("from"); + }; + + const setThisWeek = () => { + const today = new Date(); + const dayOfWeek = today.getDay(); + const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // 월요일 기준 + const monday = new Date(today); + monday.setDate(today.getDate() + diff); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + const newValue = { from: monday, to: sunday }; + setTempValue(newValue); + onChange(newValue); + setIsOpen(false); + setSelectingType("from"); + }; + + const setThisMonth = () => { + const today = new Date(); + const firstDay = new Date(today.getFullYear(), today.getMonth(), 1); + const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); + const newValue = { from: firstDay, to: lastDay }; + setTempValue(newValue); + onChange(newValue); + setIsOpen(false); + setSelectingType("from"); + }; + + const setLast7Days = () => { + const today = new Date(); + const sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(today.getDate() - 6); + const newValue = { from: sevenDaysAgo, to: today }; + setTempValue(newValue); + onChange(newValue); + setIsOpen(false); + setSelectingType("from"); + }; + + const setLast30Days = () => { + const today = new Date(); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 29); + const newValue = { from: thirtyDaysAgo, to: today }; + setTempValue(newValue); + onChange(newValue); setIsOpen(false); setSelectingType("from"); - // 날짜는 이미 선택 시점에 onChange가 호출되므로 중복 호출 제거 }; const monthStart = startOfMonth(currentMonth); @@ -91,16 +167,16 @@ export const ModernDatePicker: React.FC = ({ label, value const allDays = [...Array(paddingDays).fill(null), ...days]; const isInRange = (date: Date) => { - if (!value.from || !value.to) return false; - return date >= value.from && date <= value.to; + if (!tempValue.from || !tempValue.to) return false; + return date >= tempValue.from && date <= tempValue.to; }; const isRangeStart = (date: Date) => { - return value.from && isSameDay(date, value.from); + return tempValue.from && isSameDay(date, tempValue.from); }; const isRangeEnd = (date: Date) => { - return value.to && isSameDay(date, value.to); + return tempValue.to && isSameDay(date, tempValue.to); }; return ( @@ -127,6 +203,25 @@ export const ModernDatePicker: React.FC = ({ label, value + {/* 빠른 선택 버튼 */} +
+ + + + + +
+ {/* 월 네비게이션 */}
{/* 선택된 범위 표시 */} - {(value.from || value.to) && ( + {(tempValue.from || tempValue.to) && (
선택된 기간
- {value.from && 시작: {formatDate(value.from)}} - {value.from && value.to && ~} - {value.to && 종료: {formatDate(value.to)}} + {tempValue.from && 시작: {formatDate(tempValue.from)}} + {tempValue.from && tempValue.to && ~} + {tempValue.to && 종료: {formatDate(tempValue.to)}}
)} @@ -200,7 +295,7 @@ export const ModernDatePicker: React.FC = ({ label, value 초기화
-
diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 91d7b36f..109c5c56 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -195,17 +195,57 @@ export function ModalRepeaterTableComponent({ const columnName = component?.columnName; const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || []; - // ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출) + // ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용) const handleChange = (newData: any[]) => { + console.log("🔄 ModalRepeaterTableComponent.handleChange 호출:", { + dataLength: newData.length, + columnName, + hasExternalOnChange: !!(componentConfig?.onChange || propOnChange), + hasOnFormDataChange: !!(onFormDataChange && columnName), + }); + + // 🆕 납기일 일괄 적용 로직 (납기일 필드가 있는 경우만) + let processedData = newData; + + // 납기일 필드 찾기 (item_due_date, delivery_date, due_date 등) + const dateField = columns.find( + (col) => + col.field === "item_due_date" || + col.field === "delivery_date" || + col.field === "due_date" + ); + + if (dateField && !isDeliveryDateApplied && newData.length > 0) { + // 현재 상태: 납기일이 있는 행과 없는 행 개수 체크 + const itemsWithDate = newData.filter((item) => item[dateField.field]); + const itemsWithoutDate = newData.filter((item) => !item[dateField.field]); + + // 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용 + if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { + const selectedDate = itemsWithDate[0][dateField.field]; + processedData = newData.map((item) => ({ + ...item, + [dateField.field]: selectedDate, // 모든 행에 동일한 납기일 적용 + })); + + setIsDeliveryDateApplied(true); // 플래그 활성화 + + console.log("✅ 납기일 일괄 적용 완료:", selectedDate); + console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`); + } + } + // 기존 onChange 콜백 호출 (호환성) const externalOnChange = componentConfig?.onChange || propOnChange; if (externalOnChange) { - externalOnChange(newData); + console.log("📤 외부 onChange 호출"); + externalOnChange(processedData); } // 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트 if (onFormDataChange && columnName) { - onFormDataChange(columnName, newData); + console.log("📤 onFormDataChange 호출:", columnName); + onFormDataChange(columnName, processedData); } }; @@ -219,6 +259,9 @@ export function ModalRepeaterTableComponent({ const companyCode = componentConfig?.companyCode || propCompanyCode; const [modalOpen, setModalOpen] = useState(false); + // 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행) + const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); + // columns가 비어있으면 sourceColumns로부터 자동 생성 const columns = React.useMemo((): RepeaterColumnConfig[] => { const configuredColumns = componentConfig?.columns || propColumns || []; From c0c81f20fcfdd47d50b3b6cf4f7e78d39ccd5c45 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 26 Nov 2025 10:33:42 +0900 Subject: [PATCH 12/32] =?UTF-8?q?feat:=20=ED=92=88=EB=AA=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=BA=90=EC=8A=A4=ED=8C=85=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModalRepeaterTableComponent에 납기일 자동 일괄 적용 로직 구현 - 첫 납기일 선택 시 빈 행에 자동으로 동일 날짜 적용 - isDeliveryDateApplied 플래그로 중복 실행 방지 - ScreenModal 환경에서 onFormDataChange 경로 지원 - updateFormDataPartial에서 WHERE 조건의 PK 타입 동적 감지 - integer, numeric, uuid 등 다양한 타입에 대응 - ::text 하드코딩 제거하여 타입 불일치 에러 해결 --- backend-node/src/services/dynamicFormService.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 1ed28140..c40037bb 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -857,10 +857,22 @@ export class DynamicFormService { const values: any[] = Object.values(changedFields); values.push(id); // WHERE 조건용 ID 추가 + // 🔑 Primary Key 타입에 맞게 캐스팅 + const pkDataType = columnTypes[primaryKeyColumn]; + let pkCast = ''; + if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') { + pkCast = '::integer'; + } else if (pkDataType === 'numeric' || pkDataType === 'decimal') { + pkCast = '::numeric'; + } else if (pkDataType === 'uuid') { + pkCast = '::uuid'; + } + // text, varchar 등은 캐스팅 불필요 + const updateQuery = ` UPDATE ${tableName} SET ${setClause} - WHERE ${primaryKeyColumn} = $${values.length}::text + WHERE ${primaryKeyColumn} = $${values.length}${pkCast} RETURNING * `; From e4be76fe8d6f68833a9c1c35e1868d8e725206ce Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 26 Nov 2025 11:02:31 +0900 Subject: [PATCH 13/32] =?UTF-8?q?fix:=20=EC=88=98=EC=A3=BC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=8B=9C=20=EC=9E=AC=EC=A7=88=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModalRepeaterTableComponent의 저장 필터링 로직 개선 - columnMappings에 정의된 필드는 sourceColumns에 있어도 저장 - mappedFields 우선순위로 필터링 순서 변경 - 조인 전용 컬럼과 복사 저장 컬럼 구분 가능 --- .../12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json | 19 ------------- .../1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json | 16 ----------- .../375f2326-ca86-468a-bfc3-2d4c3825577b.json | 19 ------------- .../386e334a-df76-440c-ae8a-9bf06982fdc8.json | 16 ----------- .../3d411dc4-69a6-4236-b878-9693dff881be.json | 18 ------------ .../3e30a264-8431-44c7-96ef-eed551e66a11.json | 16 ----------- .../4a32bab5-364e-4037-bb00-31d2905824db.json | 16 ----------- .../5bfb2acd-023a-4865-a738-2900179db5fb.json | 16 ----------- .../683c1323-1895-403a-bb9a-4e111a8909f6.json | 18 ------------ .../7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json | 16 ----------- .../8990ea86-3112-4e7c-b3e0-8b494181c4e0.json | 13 --------- .../99703f2c-740c-492e-a866-a04289a9b699.json | 13 --------- .../9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json | 19 ------------- .../9d0b9fcf-cabf-4053-b6b6-6e110add22de.json | 18 ------------ .../b293e530-2b2d-4b8a-8081-d103fab5a13f.json | 18 ------------ .../cf892a77-1998-4165-bb9d-b390451465b2.json | 16 ----------- .../e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json | 13 --------- .../eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json | 16 ----------- .../fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json | 28 ------------------- .../ModalRepeaterTableComponent.tsx | 24 ++++++++++++---- 20 files changed, 19 insertions(+), 329 deletions(-) delete mode 100644 backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json delete mode 100644 backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json delete mode 100644 backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json delete mode 100644 backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json delete mode 100644 backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json delete mode 100644 backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json delete mode 100644 backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json delete mode 100644 backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json delete mode 100644 backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json delete mode 100644 backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json delete mode 100644 backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json delete mode 100644 backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json delete mode 100644 backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json delete mode 100644 backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json delete mode 100644 backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json delete mode 100644 backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json delete mode 100644 backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json delete mode 100644 backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json delete mode 100644 backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json diff --git a/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json b/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json deleted file mode 100644 index 9e7a209c..00000000 --- a/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "12b583c9-a6b2-4c7f-8340-fd0e700aa32e", - "sentAt": "2025-10-22T05:17:38.303Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "Fwd: ㅏㅣ", - "htmlContent": "\r\n
\r\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄

\r\n
\r\n

\r\n
\r\n

---------- 전달된 메시지 ----------

\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:32:34

\r\n

제목: ㅏㅣ

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [], - "deletedAt": "2025-10-22T06:36:10.876Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json b/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json deleted file mode 100644 index 2f624e9c..00000000 --- a/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "1bb5ebfe-3f6c-4884-a043-161ae3f74f75", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴㅇㄹㅇㄴㄴㄹ 테스트트트", - "htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" \n날짜: 2025. 10. 22. 오후 4:24:54\n제목: ㄴㅇㄹㅇㄴㄴㄹ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄹㅇㄴㄹㅇㄴㄹㅇㄴ\n", - "sentAt": "2025-10-22T07:49:50.811Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:49:50.811Z", - "deletedAt": "2025-10-22T07:50:14.211Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json deleted file mode 100644 index c142808d..00000000 --- a/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "375f2326-ca86-468a-bfc3-2d4c3825577b", - "sentAt": "2025-10-22T04:57:39.706Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "\"이희진\" " - ], - "subject": "Re: ㅏㅣ", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㅇㄴㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:32:34

\r\n

제목: ㅏㅣ

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [], - "deletedAt": "2025-10-22T07:11:04.666Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json b/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json deleted file mode 100644 index 31da5552..00000000 --- a/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "386e334a-df76-440c-ae8a-9bf06982fdc8", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴ", - "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\" <zian9227@naver.com>

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n

\n
\n ", - "sentAt": "2025-10-22T07:04:27.192Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:04:57.280Z", - "deletedAt": "2025-10-22T07:50:17.136Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json b/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json deleted file mode 100644 index aa107de7..00000000 --- a/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "3d411dc4-69a6-4236-b878-9693dff881be", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "cc": [], - "bcc": [], - "subject": "Re: ㄴ", - "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

undefined

\n
\n ", - "sentAt": "2025-10-22T06:56:51.060Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:56:51.060Z", - "deletedAt": "2025-10-22T07:50:22.989Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json b/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json deleted file mode 100644 index d824d67b..00000000 --- a/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "3e30a264-8431-44c7-96ef-eed551e66a11", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴ", - "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

\n
\n ", - "sentAt": "2025-10-22T06:57:53.335Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:00:23.394Z", - "deletedAt": "2025-10-22T07:50:20.510Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json b/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json deleted file mode 100644 index 92de4a0c..00000000 --- a/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "4a32bab5-364e-4037-bb00-31d2905824db", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "테스트 마지가", - "htmlContent": "ㅁㄴㅇㄹ", - "sentAt": "2025-10-22T07:49:29.948Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:49:29.948Z", - "deletedAt": "2025-10-22T07:50:12.374Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json b/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json deleted file mode 100644 index 5f5a5cfc..00000000 --- a/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "5bfb2acd-023a-4865-a738-2900179db5fb", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴ", - "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n

\n
\n ", - "sentAt": "2025-10-22T07:03:09.080Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:03:39.150Z", - "deletedAt": "2025-10-22T07:50:19.035Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json b/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json deleted file mode 100644 index b3c3259f..00000000 --- a/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "683c1323-1895-403a-bb9a-4e111a8909f6", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "cc": [], - "bcc": [], - "subject": "Re: ㄴ", - "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

undefined

\n
\n ", - "sentAt": "2025-10-22T06:54:55.097Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:54:55.097Z", - "deletedAt": "2025-10-22T07:50:24.672Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json b/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json deleted file mode 100644 index d9edbdeb..00000000 --- a/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "7bed27d5-dae4-4ba8-85d0-c474c4fb907a", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㅏㅣ", - "htmlContent": "\n

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 1:32:34

\n

제목: ㅏㅣ

\n
\n undefined\n
\n ", - "sentAt": "2025-10-22T06:41:52.984Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:46:23.051Z", - "deletedAt": "2025-10-22T07:50:29.124Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json b/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json deleted file mode 100644 index f0ed2dcf..00000000 --- a/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "8990ea86-3112-4e7c-b3e0-8b494181c4e0", - "accountName": "", - "accountEmail": "", - "to": [], - "subject": "", - "htmlContent": "", - "sentAt": "2025-10-22T06:17:31.379Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:17:31.379Z", - "deletedAt": "2025-10-22T07:50:30.736Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json b/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json deleted file mode 100644 index 1c6dc41f..00000000 --- a/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "99703f2c-740c-492e-a866-a04289a9b699", - "accountName": "", - "accountEmail": "", - "to": [], - "subject": "", - "htmlContent": "", - "sentAt": "2025-10-22T06:20:08.450Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:20:08.450Z", - "deletedAt": "2025-10-22T06:36:07.797Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json b/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json deleted file mode 100644 index 31bde67a..00000000 --- a/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e", - "sentAt": "2025-10-22T04:31:17.175Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "\"이희진\" " - ], - "subject": "Re: ㅅㄷㄴㅅ", - "htmlContent": "\r\n
\r\n

배불르고 졸린데 커피먹으니깐 졸린건 괜찮아졋고 배불러서 물배찼당아아아아

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [], - "deletedAt": "2025-10-22T07:11:10.245Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json b/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json deleted file mode 100644 index 2ace7d67..00000000 --- a/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "9d0b9fcf-cabf-4053-b6b6-6e110add22de", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "cc": [], - "bcc": [], - "subject": "Re: ㅏㅣ", - "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 1:32:34

\n

제목: ㅏㅣ

\n
\n

undefined

\n
\n ", - "sentAt": "2025-10-22T06:50:04.224Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:50:04.224Z", - "deletedAt": "2025-10-22T07:50:26.224Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json b/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json deleted file mode 100644 index 77d9053f..00000000 --- a/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "b293e530-2b2d-4b8a-8081-d103fab5a13f", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "cc": [], - "bcc": [], - "subject": "Re: 수신메일확인용", - "htmlContent": "\n

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 13. 오전 10:40:30

\n

제목: 수신메일확인용

\n
\n undefined\n
\n ", - "sentAt": "2025-10-22T06:47:53.815Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:48:53.876Z", - "deletedAt": "2025-10-22T07:50:27.706Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json b/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json deleted file mode 100644 index 426f81fb..00000000 --- a/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "cf892a77-1998-4165-bb9d-b390451465b2", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "Fwd: ㄴ", - "htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" \n날짜: 2025. 10. 22. 오후 12:58:15\n제목: ㄴ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n", - "sentAt": "2025-10-22T07:06:11.620Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T07:07:11.749Z", - "deletedAt": "2025-10-22T07:50:15.739Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json b/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json deleted file mode 100644 index cf31f7dc..00000000 --- a/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "e3501abc-cd31-4b20-bb02-3c7ddbe54eb8", - "accountName": "", - "accountEmail": "", - "to": [], - "subject": "", - "htmlContent": "", - "sentAt": "2025-10-22T06:15:02.128Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:15:02.128Z", - "deletedAt": "2025-10-22T07:08:43.543Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json b/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json deleted file mode 100644 index 0c19dc0c..00000000 --- a/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "eb92ed00-cc4f-4cc8-94c9-9bef312d16db", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [], - "cc": [], - "bcc": [], - "subject": "메일 임시저장 테스트 4", - "htmlContent": "asd", - "sentAt": "2025-10-22T06:21:40.019Z", - "status": "draft", - "isDraft": true, - "updatedAt": "2025-10-22T06:21:40.019Z", - "deletedAt": "2025-10-22T06:36:05.306Z" -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json b/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json deleted file mode 100644 index 073c20f0..00000000 --- a/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "id": "fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082", - "sentAt": "2025-10-22T04:29:14.738Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "\"이희진\" " - ], - "subject": "Re: ㅅㄷㄴㅅ", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", - "attachments": [ - { - "filename": "test용 이미지2.png", - "originalName": "test용 이미지2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1761107350246-298369766.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [], - "deletedAt": "2025-10-22T07:11:12.907Z" -} \ No newline at end of file diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 109c5c56..56f04d26 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -312,18 +312,32 @@ export function ModalRepeaterTableComponent({ } // sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거) + // 단, columnMappings에 정의된 컬럼은 저장해야 하므로 제외하지 않음 + const mappedFields = columns + .filter(col => col.mapping?.type === "source" && col.mapping?.sourceField) + .map(col => col.field); + const filteredData = value.map((item: any) => { const filtered: Record = {}; Object.keys(item).forEach((key) => { - // sourceColumns에 포함된 컬럼은 제외 (item_info 테이블의 컬럼) - if (sourceColumns.includes(key)) { - return; - } - // 메타데이터 필드도 제외 + // 메타데이터 필드 제외 if (key.startsWith("_")) { return; } + + // sourceColumns에 포함되어 있지만 columnMappings에도 정의된 경우 → 저장함 + if (mappedFields.includes(key)) { + filtered[key] = item[key]; + return; + } + + // sourceColumns에만 있고 매핑 안 된 경우 → 제외 (조인 전용) + if (sourceColumns.includes(key)) { + return; + } + + // 나머지는 모두 저장 filtered[key] = item[key]; }); From acc2a6169d86d43fc41b49ee6ffad1b182ab8e79 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 26 Nov 2025 14:05:22 +0900 Subject: [PATCH 14/32] =?UTF-8?q?style:=20EditModal=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trailing whitespace 정리 - 들여쓰기 일관성 유지 - 그룹 편집 안내 메시지 UI 제거 --- frontend/components/screen/EditModal.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index e44eef4e..9945a19c 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -686,13 +686,6 @@ export const EditModal: React.FC = ({ className }) => { maxHeight: "100%", }} > - {/* 🆕 그룹 데이터가 있으면 안내 메시지 표시 */} - {groupData.length > 1 && ( -
- {groupData.length}개의 관련 품목을 함께 수정합니다 -
- )} - {screenData.components.map((component) => { // 컴포넌트 위치를 offset만큼 조정 const offsetX = screenDimensions?.offsetX || 0; From 13fe9c97feb172283fcb541ef702b1cd568fe14d Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 26 Nov 2025 14:44:49 +0900 Subject: [PATCH 15/32] =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/ScreenList.tsx | 21 +- .../config-panels/CheckboxConfigPanel.tsx | 71 ++- .../config-panels/EntityConfigPanel.tsx | 73 ++- .../screen/config-panels/RadioConfigPanel.tsx | 55 +- .../config-panels/SelectConfigPanel.tsx | 49 +- .../screen/templates/NumberingRuleTemplate.ts | 1 + .../webtypes/config/RepeaterConfigPanel.tsx | 71 ++- frontend/lib/api/tableCategoryValue.ts | 19 + .../SelectedItemsDetailInputConfigPanel.tsx | 594 +++++++++++++----- 동적_테이블_접근_시스템_개선_완료.md | 1 + 10 files changed, 712 insertions(+), 243 deletions(-) diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index d2d3e367..eff55312 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -47,6 +47,7 @@ import dynamic from "next/dynamic"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicWebTypeRenderer } from "@/lib/registry"; import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils"; +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // InteractiveScreenViewer를 동적으로 import (SSR 비활성화) const InteractiveScreenViewer = dynamic( @@ -1315,15 +1316,16 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr 화면 미리보기 - {screenToPreview?.screenName} -
- {isLoadingPreview ? ( -
-
-
레이아웃 로딩 중...
-
화면 정보를 불러오고 있습니다.
+ +
+ {isLoadingPreview ? ( +
+
+
레이아웃 로딩 중...
+
화면 정보를 불러오고 있습니다.
+
-
- ) : previewLayout && previewLayout.components ? ( + ) : previewLayout && previewLayout.components ? ( (() => { const screenWidth = previewLayout.screenResolution?.width || 1200; const screenHeight = previewLayout.screenResolution?.height || 800; @@ -1536,7 +1538,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
)} -
+ + @@ -1702,30 +1978,40 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {/* 2단계: 카테고리 선택 */} - {(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && ( -
- - -
- )} + {(() => { + const hasSelectedMenu = !!(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType; + const columns = categoryColumns.discountType || []; + console.log("🎨 [렌더링] 2단계 카테고리 선택", { + hasSelectedMenu, + columns, + columnsCount: columns.length, + categoryColumnsState: categoryColumns + }); + return hasSelectedMenu ? ( +
+ + +
+ ) : null; + })()} {/* 3단계: 값 매핑 */} {(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType && ( @@ -1780,14 +2066,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {/* 반올림 방식 매핑 */} - + setExpandedCategoryMappings(prev => ({ ...prev, roundingType: open }))} + > @@ -1890,14 +2183,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {/* 반올림 단위 매핑 */} - + setExpandedCategoryMappings(prev => ({ ...prev, roundingUnit: open }))} + > @@ -2235,10 +2535,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {mapping.targetField - ? targetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel || + ? loadedTargetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel || mapping.targetField : "저장 테이블 컬럼 선택"} @@ -2248,13 +2548,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - {targetTableColumns.length === 0 ? ( - 저장 테이블을 먼저 선택하세요 + {!config.targetTable ? ( + 저장 대상 테이블을 먼저 선택하세요 + ) : loadedTargetTableColumns.length === 0 ? ( + 컬럼 로딩 중... ) : ( <> 컬럼을 찾을 수 없습니다. - {targetTableColumns.map((col) => { + {loadedTargetTableColumns.map((col) => { const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase(); return ( {col.columnLabel || col.columnName} {col.dataType && ( - {col.dataType} + + {col.dataType} + )} @@ -2289,17 +2593,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC +

+ 현재 화면의 저장 대상 테이블 ({config.targetTable || "미선택"})의 컬럼 +

{/* 기본값 (선택사항) */}
{ - const updated = [...(config.parentDataMapping || [])]; - updated[index] = { ...updated[index], defaultValue: e.target.value }; - handleChange("parentDataMapping", updated); + const newValue = e.target.value; + setLocalMappingInputs(prev => ({ ...prev, [index]: newValue })); + }} + onBlur={() => { + const currentValue = localMappingInputs[index]; + if (currentValue !== undefined) { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], defaultValue: currentValue || undefined }; + handleChange("parentDataMapping", updated); + } }} placeholder="값이 없을 때 사용할 기본값" className="h-7 text-xs" @@ -2307,46 +2621,24 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {/* 삭제 버튼 */} - +
+ +
))} - - {(config.parentDataMapping || []).length === 0 && ( -

- 매핑 설정이 없습니다. "추가" 버튼을 클릭하여 설정하세요. -

- )} - - {/* 예시 */} -
-

💡 예시

-
-

매핑 1: 거래처 ID

-

• 소스 테이블: customer_mng

-

• 원본 필드: id → 저장 필드: customer_id

- -

매핑 2: 품목 ID

-

• 소스 테이블: item_info

-

• 원본 필드: id → 저장 필드: item_id

- -

매핑 3: 품목 기준단가

-

• 소스 테이블: item_info

-

• 원본 필드: standard_price → 저장 필드: base_price

-
-
{/* 사용 예시 */} @@ -2363,3 +2655,5 @@ export const SelectedItemsDetailInputConfigPanel: React.FC Date: Wed, 26 Nov 2025 14:58:18 +0900 Subject: [PATCH 16/32] =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/ScreenList.tsx | 192 +++++++----------- .../table-search-widget/TableSearchWidget.tsx | 16 +- 2 files changed, 87 insertions(+), 121 deletions(-) diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index eff55312..8e5ba1d2 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -48,6 +48,8 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere import { DynamicWebTypeRenderer } from "@/lib/registry"; import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; +import { RealtimePreview } from "./RealtimePreviewDynamic"; +import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; // InteractiveScreenViewer를 동적으로 import (SSR 비활성화) const InteractiveScreenViewer = dynamic( @@ -1316,8 +1318,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr 화면 미리보기 - {screenToPreview?.screenName} - -
+ + +
{isLoadingPreview ? (
@@ -1331,10 +1334,24 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const screenHeight = previewLayout.screenResolution?.height || 800; // 모달 내부 가용 공간 계산 (헤더, 푸터, 패딩 제외) - const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - 100 : 1800; // 95vw - 패딩 + const modalPadding = 100; // 헤더 + 푸터 + 패딩 + const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - modalPadding : 1700; + const availableHeight = typeof window !== "undefined" ? window.innerHeight * 0.95 - modalPadding : 900; - // 가로폭 기준으로 스케일 계산 (가로폭에 맞춤) - const scale = availableWidth / screenWidth; + // 가로/세로 비율을 모두 고려하여 작은 쪽에 맞춤 (화면이 잘리지 않도록) + const scaleX = availableWidth / screenWidth; + const scaleY = availableHeight / screenHeight; + const scale = Math.min(scaleX, scaleY, 1); // 최대 1배율 (확대 방지) + + console.log("📐 미리보기 스케일 계산:", { + screenWidth, + screenHeight, + availableWidth, + availableHeight, + scaleX, + scaleY, + finalScale: scale, + }); return (
- {/* 라벨을 외부에 별도로 렌더링 */} - {shouldShowLabel && ( -
- {labelText} - {component.required && *} -
- )} + {}} + screenId={screenToPreview!.screenId} + tableName={screenToPreview?.tableName} + formData={previewFormData} + onFormDataChange={(fieldName, value) => { + setPreviewFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || + component.type === "container" || + component.type === "area") && + previewLayout.components + .filter((child: any) => child.parentId === component.id) + .map((child: any) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; - {/* 실제 컴포넌트 */} -
{ - const style = { - position: "absolute" as const, - left: `${component.position.x}px`, - top: `${component.position.y}px`, - width: component.style?.width || `${component.size.width}px`, - height: component.style?.height || `${component.size.height}px`, - zIndex: component.position.z || 1, - }; - - return style; - })()} - > - {/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */} - {component.type !== "widget" ? ( - { - setPreviewFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }} - screenId={screenToPreview!.screenId} - tableName={screenToPreview?.tableName} - /> - ) : ( - { - // 유틸리티 함수로 파일 컴포넌트 감지 - if (isFileComponent(component)) { - return "file"; - } - // 다른 컴포넌트는 유틸리티 함수로 webType 결정 - return getComponentWebType(component) || "text"; - })()} - config={component.webTypeConfig} - props={{ - component: component, - value: previewFormData[component.columnName || component.id] || "", - onChange: (value: any) => { - const fieldName = component.columnName || component.id; - setPreviewFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }, - onFormDataChange: (fieldName, value) => { - setPreviewFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }, - isInteractive: true, - formData: previewFormData, - readonly: component.readonly, - required: component.required, - placeholder: component.placeholder, - className: "w-full h-full", - }} - /> - )} -
-
+ return ( + {}} + screenId={screenToPreview!.screenId} + tableName={screenToPreview?.tableName} + formData={previewFormData} + onFormDataChange={(fieldName, value) => { + setPreviewFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + /> + ); + })} + ); })}
@@ -1538,8 +1501,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
)} -
-
+ + +