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/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/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 aa46ed40..41e321e5 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; @@ -61,6 +62,7 @@ export const InteractiveScreenViewerDynamic: 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/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx index 724c2453..46b4d799 100644 --- a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx @@ -9,13 +9,14 @@ import { Switch } from "@/components/ui/switch"; import { Trash2, Plus } from "lucide-react"; import { ColumnFilter, DataFilterConfig } from "@/types/screen-management"; import { UnifiedColumnInfo } from "@/types/table-management"; -import { apiClient } from "@/lib/api/client"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; interface DataFilterConfigPanelProps { tableName?: string; columns?: UnifiedColumnInfo[]; config?: DataFilterConfig; onConfigChange: (config: DataFilterConfig) => 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/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/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}
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} + ))}
); } 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..7e024776 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,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(config.fieldGroups || []); + // 🆕 그룹 입력값을 위한 로컬 상태 (포커스 유지용) + const [localGroupInputs, setLocalGroupInputs] = useState>({}); + + // 🆕 표시 항목의 입력값을 위한 로컬 상태 (포커스 유지용) + const [localDisplayItemInputs, setLocalDisplayItemInputs] = useState>>({}); + // 🆕 그룹별 펼침/접힘 상태 const [expandedGroups, setExpandedGroups] = useState>({}); @@ -140,6 +146,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + setLocalFieldGroups(config.fieldGroups || []); + + // 로컬 입력 상태는 기존 값 보존하면서 새 그룹만 추가 + setLocalGroupInputs(prev => { + const newInputs = { ...prev }; + (config.fieldGroups || []).forEach(group => { + if (!(group.id in newInputs)) { + newInputs[group.id] = { + id: group.id, + title: group.title, + description: group.description, + order: group.order, + }; + } + }); + return newInputs; + }); + }, [config.fieldGroups]); + // 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드 useEffect(() => { if (!localFields || localFields.length === 0) return; @@ -343,6 +370,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + // 로컬 입력 상태에서 해당 그룹 제거 + setLocalGroupInputs(prev => { + const newInputs = { ...prev }; + delete newInputs[groupId]; + return newInputs; + }); + // 그룹 삭제 시 해당 그룹에 속한 필드들의 groupId도 제거 const updatedFields = localFields.map(field => field.groupId === groupId ? { ...field, groupId: undefined } : field @@ -353,6 +387,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC) => { + // 1. 로컬 입력 상태 즉시 업데이트 (포커스 유지) + setLocalGroupInputs(prev => ({ + ...prev, + [groupId]: { ...prev[groupId], ...updates } + })); + + // 2. 실제 그룹 데이터 업데이트 const newGroups = localFieldGroups.map(g => g.id === groupId ? { ...g, ...updates } : g ); @@ -1036,8 +1077,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateFieldGroup(group.id, { id: e.target.value })} + value={localGroupInputs[group.id]?.id !== undefined ? localGroupInputs[group.id].id : group.id} + onChange={(e) => { + const newValue = e.target.value; + setLocalGroupInputs(prev => ({ + ...prev, + [group.id]: { ...prev[group.id], id: newValue } + })); + updateFieldGroup(group.id, { id: newValue }); + }} className="h-7 text-xs sm:h-8 sm:text-sm" placeholder="group_customer" /> @@ -1047,8 +1095,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateFieldGroup(group.id, { title: e.target.value })} + value={localGroupInputs[group.id]?.title !== undefined ? localGroupInputs[group.id].title : group.title} + onChange={(e) => { + const newValue = e.target.value; + setLocalGroupInputs(prev => ({ + ...prev, + [group.id]: { ...prev[group.id], title: newValue } + })); + updateFieldGroup(group.id, { title: newValue }); + }} className="h-7 text-xs sm:h-8 sm:text-sm" placeholder="거래처 정보" /> @@ -1058,8 +1113,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateFieldGroup(group.id, { description: e.target.value })} + value={localGroupInputs[group.id]?.description !== undefined ? localGroupInputs[group.id].description : (group.description || "")} + onChange={(e) => { + const newValue = e.target.value; + setLocalGroupInputs(prev => ({ + ...prev, + [group.id]: { ...prev[group.id], description: newValue } + })); + updateFieldGroup(group.id, { description: newValue }); + }} className="h-7 text-xs sm:h-8 sm:text-sm" placeholder="거래처 관련 정보를 입력합니다" /> @@ -1070,8 +1132,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC표시 순서 updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })} + value={localGroupInputs[group.id]?.order !== undefined ? localGroupInputs[group.id].order : (group.order || 0)} + onChange={(e) => { + const newValue = parseInt(e.target.value) || 0; + setLocalGroupInputs(prev => ({ + ...prev, + [group.id]: { ...prev[group.id], order: newValue } + })); + updateFieldGroup(group.id, { order: newValue }); + }} className="h-7 text-xs sm:h-8 sm:text-sm" min="0" /> @@ -1177,8 +1246,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 +1294,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) => { diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx index 0416c4b3..80144c82 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -11,6 +11,7 @@ import { FilterPanel } from "@/components/screen/table-options/FilterPanel"; import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel"; import { TableFilter } from "@/types/table-options"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker"; interface PresetFilter { id: string; @@ -62,7 +63,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table // 활성화된 필터 목록 const [activeFilters, setActiveFilters] = useState([]); - const [filterValues, setFilterValues] = useState>({}); + const [filterValues, setFilterValues] = useState>({}); // select 타입 필터의 옵션들 const [selectOptions, setSelectOptions] = useState>>({}); // 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지) @@ -230,7 +231,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table const hasMultipleTables = tableList.length > 1; // 필터 값 변경 핸들러 - const handleFilterChange = (columnName: string, value: string) => { + const handleFilterChange = (columnName: string, value: any) => { const newValues = { ...filterValues, [columnName]: value, @@ -243,14 +244,51 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table }; // 필터 적용 함수 - const applyFilters = (values: Record = filterValues) => { + const applyFilters = (values: Record = filterValues) => { // 빈 값이 아닌 필터만 적용 const filtersWithValues = activeFilters - .map((filter) => ({ - ...filter, - value: values[filter.columnName] || "", - })) - .filter((f) => f.value !== ""); + .map((filter) => { + let filterValue = values[filter.columnName]; + + // 날짜 범위 객체를 처리 + if (filter.filterType === "date" && filterValue && typeof filterValue === "object" && (filterValue.from || filterValue.to)) { + // 날짜 범위 객체를 문자열 형식으로 변환 (백엔드 재시작 불필요) + const formatDate = (date: Date) => { + 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}`; + }; + + // "YYYY-MM-DD|YYYY-MM-DD" 형식으로 변환 + const fromStr = filterValue.from ? formatDate(filterValue.from) : ""; + const toStr = filterValue.to ? formatDate(filterValue.to) : ""; + + if (fromStr && toStr) { + // 둘 다 있으면 파이프로 연결 + filterValue = `${fromStr}|${toStr}`; + } else if (fromStr) { + // 시작일만 있으면 + filterValue = `${fromStr}|`; + } else if (toStr) { + // 종료일만 있으면 + filterValue = `|${toStr}`; + } else { + filterValue = ""; + } + } + + return { + ...filter, + value: filterValue || "", + }; + }) + .filter((f) => { + // 빈 값 체크 + if (!f.value) return false; + if (typeof f.value === "string" && f.value === "") return false; + return true; + }); currentTable?.onFilterChange(filtersWithValues); }; @@ -271,14 +309,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table switch (filter.filterType) { case "date": return ( - handleFilterChange(filter.columnName, e.target.value)} - className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm" - style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }} - placeholder={column?.columnLabel} - /> +
+ { + if (dateRange.from && dateRange.to) { + // 기간이 선택되면 from과 to를 모두 저장 + handleFilterChange(filter.columnName, dateRange); + } else { + handleFilterChange(filter.columnName, ""); + } + }} + includeTime={false} + /> +
); case "number":