From 2b8a3945a1791bd0e08b7f77a951820774034ff2 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 25 Nov 2025 15:22:50 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20Section=20Paper=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=EC=98=81=EC=97=AD=EA=B3=BC=20=EC=BB=A8=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=A0=95=EB=A0=AC=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=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 2/5] =?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 3/5] =?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 ea88cfd0435ccf26461eda738ae34c3685cc3f1b Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 17:48:23 +0900 Subject: [PATCH 4/5] =?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 초기화
-