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/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e9485620..c40037bb 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; // 문자열 그대로 유지 (이미 올바른 형식) } } }); @@ -849,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 * `; 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/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 2fb83df4..290109f3 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -1108,14 +1108,11 @@ export default function TableManagementPage() { ) : (
{/* 컬럼 헤더 */} -
-
컬럼명
-
라벨
-
입력 타입
-
- 상세 설정 -
-
설명
+
+
컬럼명
+
라벨
+
입력 타입
+
설명
{/* 컬럼 리스트 */} @@ -1132,12 +1129,13 @@ export default function TableManagementPage() { {columns.map((column, index) => (
-
+
{column.columnName}
-
+
handleLabelChange(column.columnName, e.target.value)} @@ -1145,107 +1143,106 @@ export default function TableManagementPage() { className="h-8 text-xs" />
-
- -
-
- {/* 입력 타입이 'code'인 경우 공통코드 선택 */} - {column.inputType === "code" && ( +
+
+ {/* 입력 타입 선택 */} - )} - {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} - {column.inputType === "category" && ( -
- -
- {secondLevelMenus.length === 0 ? ( -

- 2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다. -

- ) : ( - secondLevelMenus.map((menu) => { - // menuObjid를 숫자로 변환하여 비교 - const menuObjidNum = Number(menu.menuObjid); - const isChecked = (column.categoryMenus || []).includes(menuObjidNum); - - return ( -
- { - const currentMenus = column.categoryMenus || []; - const newMenus = e.target.checked - ? [...currentMenus, menuObjidNum] - : currentMenus.filter((id) => id !== menuObjidNum); + {/* 입력 타입이 'code'인 경우 공통코드 선택 */} + {column.inputType === "code" && ( + + )} + {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} + {column.inputType === "category" && ( +
+ +
+ {secondLevelMenus.length === 0 ? ( +

+ 2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다. +

+ ) : ( + secondLevelMenus.map((menu) => { + // menuObjid를 숫자로 변환하여 비교 + const menuObjidNum = Number(menu.menuObjid); + const isChecked = (column.categoryMenus || []).includes(menuObjidNum); + + return ( +
+ { + const currentMenus = column.categoryMenus || []; + const newMenus = e.target.checked + ? [...currentMenus, menuObjidNum] + : currentMenus.filter((id) => id !== menuObjidNum); - setColumns((prev) => - prev.map((col) => - col.columnName === column.columnName - ? { ...col, categoryMenus: newMenus } - : col - ) - ); - }} - className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" - /> - -
- ); - }) + setColumns((prev) => + prev.map((col) => + col.columnName === column.columnName + ? { ...col, categoryMenus: newMenus } + : col + ) + ); + }} + className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" + /> + +
+ ); + }) + )} +
+ {column.categoryMenus && column.categoryMenus.length > 0 && ( +

+ {column.categoryMenus.length}개 메뉴 선택됨 +

)}
- {column.categoryMenus && column.categoryMenus.length > 0 && ( -

- {column.categoryMenus.length}개 메뉴 선택됨 -

- )} -
- )} - {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} - {column.inputType === "entity" && ( -
-
+ )} + {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} + {column.inputType === "entity" && ( + <> {/* 참조 테이블 */} -
+
@@ -1255,7 +1252,7 @@ export default function TableManagementPage() { handleDetailSettingsChange(column.columnName, "entity", value) } > - + @@ -1278,7 +1275,7 @@ export default function TableManagementPage() { {/* 조인 컬럼 */} {column.referenceTable && column.referenceTable !== "none" && ( -
+
@@ -1292,7 +1289,7 @@ export default function TableManagementPage() { ) } > - + @@ -1324,7 +1321,7 @@ export default function TableManagementPage() { column.referenceTable !== "none" && column.referenceColumn && column.referenceColumn !== "none" && ( -
+
@@ -1338,7 +1335,7 @@ export default function TableManagementPage() { ) } > - + @@ -1364,37 +1361,29 @@ export default function TableManagementPage() {
)} -
- {/* 설정 완료 표시 */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( -
- - - {column.columnName} → {column.referenceTable}.{column.displayColumn} - -
- )} -
- )} - {/* 다른 입력 타입인 경우 빈 공간 */} - {column.inputType !== "code" && column.inputType !== "category" && column.inputType !== "entity" && ( -
- - -
- )} + {/* 설정 완료 표시 */} + {column.referenceTable && + column.referenceTable !== "none" && + column.referenceColumn && + column.referenceColumn !== "none" && + column.displayColumn && + column.displayColumn !== "none" && ( +
+ + 설정 완료 +
+ )} + + )} +
-
+
handleColumnChange(index, "description", e.target.value)} placeholder="설명" - className="h-8 text-xs" + className="h-8 w-full text-xs" />
@@ -1585,3 +1574,4 @@ export default function TableManagementPage() {
); } + 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/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/order/OrderItemRepeaterTable.tsx b/frontend/components/order/OrderItemRepeaterTable.tsx index dd38ee5a..dbfe5eee 100644 --- a/frontend/components/order/OrderItemRepeaterTable.tsx +++ b/frontend/components/order/OrderItemRepeaterTable.tsx @@ -75,6 +75,13 @@ const ORDER_COLUMNS: RepeaterColumnConfig[] = [ calculated: true, width: "120px", }, + { + field: "order_date", + label: "수주일", + type: "date", + editable: true, + width: "130px", + }, { field: "delivery_date", label: "납기일", diff --git a/frontend/components/order/OrderRegistrationModal.tsx b/frontend/components/order/OrderRegistrationModal.tsx index bd780038..615f0426 100644 --- a/frontend/components/order/OrderRegistrationModal.tsx +++ b/frontend/components/order/OrderRegistrationModal.tsx @@ -64,6 +64,9 @@ export function OrderRegistrationModal({ // 선택된 품목 목록 const [selectedItems, setSelectedItems] = useState([]); + // 납기일 일괄 적용 플래그 (딱 한 번만 실행) + const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); + // 저장 중 const [isSaving, setIsSaving] = useState(false); @@ -158,6 +161,45 @@ export function OrderRegistrationModal({ hsCode: "", }); setSelectedItems([]); + setIsDeliveryDateApplied(false); // 플래그 초기화 + }; + + // 품목 목록 변경 핸들러 (납기일 일괄 적용 로직 포함) + const handleItemsChange = (newItems: any[]) => { + // 1️⃣ 플래그가 이미 true면 그냥 업데이트만 (일괄 적용 완료 상태) + if (isDeliveryDateApplied) { + setSelectedItems(newItems); + return; + } + + // 2️⃣ 품목이 없으면 그냥 업데이트 + if (newItems.length === 0) { + setSelectedItems(newItems); + return; + } + + // 3️⃣ 현재 상태: 납기일이 있는 행과 없는 행 개수 체크 + const itemsWithDate = newItems.filter((item) => item.delivery_date); + const itemsWithoutDate = newItems.filter((item) => !item.delivery_date); + + // 4️⃣ 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용 + if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { + // 5️⃣ 전체 일괄 적용 + const selectedDate = itemsWithDate[0].delivery_date; + const updatedItems = newItems.map((item) => ({ + ...item, + delivery_date: selectedDate, // 모든 행에 동일한 납기일 적용 + })); + + setSelectedItems(updatedItems); + setIsDeliveryDateApplied(true); // 플래그 활성화 (다음부터는 일괄 적용 안 함) + + console.log("✅ 납기일 일괄 적용 완료:", selectedDate); + console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`); + } else { + // 그냥 업데이트 + setSelectedItems(newItems); + } }; // 전체 금액 계산 @@ -338,7 +380,7 @@ export function OrderRegistrationModal({
diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index f9b803b2..9945a19c 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]; } }); @@ -631,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; 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 화면 미리보기 - {screenToPreview?.screenName} -
- {isLoadingPreview ? ( -
-
-
레이아웃 로딩 중...
-
화면 정보를 불러오고 있습니다.
+ + +
+ {isLoadingPreview ? ( +
+
+
레이아웃 로딩 중...
+
화면 정보를 불러오고 있습니다.
+
-
- ) : previewLayout && previewLayout.components ? ( + ) : previewLayout && previewLayout.components ? ( (() => { const screenWidth = previewLayout.screenResolution?.width || 1200; 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, + })); + }} + /> + ); + })} + ); })}
@@ -1536,7 +1501,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
)} -
+
+ +
+ {/* 빠른 선택 버튼 */} +
+ + + + + +
+ {/* 월 네비게이션 */}
{/* 선택된 범위 표시 */} - {(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 604bca3d..fb93f085 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/components/webtypes/config/RepeaterConfigPanel.tsx b/frontend/components/webtypes/config/RepeaterConfigPanel.tsx index 5d534fb6..c9e44264 100644 --- a/frontend/components/webtypes/config/RepeaterConfigPanel.tsx +++ b/frontend/components/webtypes/config/RepeaterConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -34,6 +34,21 @@ export const RepeaterConfigPanel: React.FC = ({ }) => { const [localFields, setLocalFields] = useState(config.fields || []); const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState>({}); + + // 로컬 입력 상태 (각 필드의 라벨, placeholder 등) + const [localInputs, setLocalInputs] = useState>({}); + + // 설정 입력 필드의 로컬 상태 + const [localConfigInputs, setLocalConfigInputs] = useState({ + addButtonText: config.addButtonText || "", + }); + + // config 변경 시 로컬 상태 동기화 + useEffect(() => { + setLocalConfigInputs({ + addButtonText: config.addButtonText || "", + }); + }, [config.addButtonText]); // 이미 사용된 컬럼명 목록 const usedColumnNames = useMemo(() => { @@ -72,7 +87,32 @@ export const RepeaterConfigPanel: React.FC = ({ handleFieldsChange(localFields.filter((_, i) => i !== index)); }; - // 필드 수정 + // 필드 수정 (입력 중 - 로컬 상태만) + const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => { + setLocalInputs(prev => ({ + ...prev, + [index]: { + ...prev[index], + [field]: value + } + })); + }; + + // 필드 수정 완료 (onBlur - 실제 업데이트) + const handleFieldBlur = (index: number) => { + const localInput = localInputs[index]; + if (localInput) { + const newFields = [...localFields]; + newFields[index] = { + ...newFields[index], + label: localInput.label, + placeholder: localInput.placeholder + }; + handleFieldsChange(newFields); + } + }; + + // 필드 수정 (즉시 반영 - 드롭다운, 체크박스 등) const updateField = (index: number, updates: Partial) => { const newFields = [...localFields]; newFields[index] = { ...newFields[index], ...updates }; @@ -157,7 +197,7 @@ export const RepeaterConfigPanel: React.FC = ({ {localFields.map((field, index) => ( - +
필드 {index + 1} @@ -200,6 +240,14 @@ export const RepeaterConfigPanel: React.FC = ({ label: column.columnLabel || column.columnName, type: (column.widgetType as RepeaterFieldType) || "text", }); + // 로컬 입력 상태도 업데이트 + setLocalInputs(prev => ({ + ...prev, + [index]: { + label: column.columnLabel || column.columnName, + placeholder: prev[index]?.placeholder || "" + } + })); setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false }); }} className="text-xs" @@ -225,8 +273,9 @@ export const RepeaterConfigPanel: React.FC = ({
updateField(index, { label: e.target.value })} + value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label} + onChange={(e) => updateFieldLocal(index, 'label', e.target.value)} + onBlur={() => handleFieldBlur(index)} placeholder="필드 라벨" className="h-8 w-full text-xs" /> @@ -258,10 +307,11 @@ export const RepeaterConfigPanel: React.FC = ({
updateField(index, { placeholder: e.target.value })} + value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")} + onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)} + onBlur={() => handleFieldBlur(index)} placeholder="입력 안내" - className="h-8 w-full" + className="h-8 w-full text-xs" />
@@ -329,8 +379,9 @@ export const RepeaterConfigPanel: React.FC = ({ handleChange("addButtonText", e.target.value)} + value={localConfigInputs.addButtonText} + onChange={(e) => setLocalConfigInputs({ ...localConfigInputs, addButtonText: e.target.value })} + onBlur={() => handleChange("addButtonText", localConfigInputs.addButtonText)} placeholder="항목 추가" className="h-8" /> diff --git a/frontend/lib/api/tableCategoryValue.ts b/frontend/lib/api/tableCategoryValue.ts index 9316beb0..ba830457 100644 --- a/frontend/lib/api/tableCategoryValue.ts +++ b/frontend/lib/api/tableCategoryValue.ts @@ -20,6 +20,25 @@ export async function getCategoryColumns(tableName: string) { } } +/** + * 메뉴별 카테고리 컬럼 목록 조회 + * + * @param menuObjid 메뉴 OBJID + * @returns 해당 메뉴와 상위 메뉴들이 설정한 모든 카테고리 컬럼 + */ +export async function getCategoryColumnsByMenu(menuObjid: number) { + try { + const response = await apiClient.get<{ + success: boolean; + data: CategoryColumn[]; + }>(`/table-management/menu/${menuObjid}/category-columns`); + return response.data; + } catch (error: any) { + console.error("메뉴별 카테고리 컬럼 조회 실패:", error); + return { success: false, error: error.message }; + } +} + /** * 카테고리 값 목록 조회 (메뉴 스코프) * 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..6302e7f9 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -195,17 +195,69 @@ 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[]) => { + // 🆕 납기일 일괄 적용 로직 (납기일 필드가 있는 경우만) + 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); // 플래그 활성화 + } + } + + // 🆕 수주일 일괄 적용 로직 (order_date 필드가 있는 경우만) + const orderDateField = columns.find( + (col) => + col.field === "order_date" || + col.field === "ordered_date" + ); + + if (orderDateField && !isOrderDateApplied && newData.length > 0) { + // ⚠️ 중요: 원본 newData를 참조해야 납기일의 영향을 받지 않음 + const itemsWithOrderDate = newData.filter((item) => item[orderDateField.field]); + const itemsWithoutOrderDate = newData.filter((item) => !item[orderDateField.field]); + + // ✅ 조건: 모든 행이 비어있는 초기 상태 → 어느 행에서든 첫 선택 시 전체 적용 + if (itemsWithOrderDate.length === 1 && itemsWithoutOrderDate.length === newData.length - 1) { + const selectedOrderDate = itemsWithOrderDate[0][orderDateField.field]; + processedData = processedData.map((item) => ({ + ...item, + [orderDateField.field]: selectedOrderDate, + })); + + setIsOrderDateApplied(true); // 플래그 활성화 + } + } + // 기존 onChange 콜백 호출 (호환성) const externalOnChange = componentConfig?.onChange || propOnChange; if (externalOnChange) { - externalOnChange(newData); + externalOnChange(processedData); } // 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트 if (onFormDataChange && columnName) { - onFormDataChange(columnName, newData); + onFormDataChange(columnName, processedData); } }; @@ -219,18 +271,22 @@ export function ModalRepeaterTableComponent({ const companyCode = componentConfig?.companyCode || propCompanyCode; const [modalOpen, setModalOpen] = useState(false); + // 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행) + const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); + + // 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행) + const [isOrderDateApplied, setIsOrderDateApplied] = useState(false); + // columns가 비어있으면 sourceColumns로부터 자동 생성 const columns = React.useMemo((): RepeaterColumnConfig[] => { 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,99 +294,72 @@ 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에 포함된 컬럼 제외 (조인된 컬럼 제거) + // 단, 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)) { - console.log(` ⛔ ${key} 제외 (sourceColumn)`); - return; - } - // 메타데이터 필드도 제외 + // 메타데이터 필드 제외 if (key.startsWith("_")) { - console.log(` ⛔ ${key} 제외 (메타데이터)`); return; } + + // sourceColumns에 포함되어 있지만 columnMappings에도 정의된 경우 → 저장함 + if (mappedFields.includes(key)) { + filtered[key] = item[key]; + return; + } + + // sourceColumns에만 있고 매핑 안 된 경우 → 제외 (조인 전용) + if (sourceColumns.includes(key)) { + return; + } + + // 나머지는 모두 저장 filtered[key] = item[key]; }); 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 +367,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/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx index a8068c92..348ae045 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx @@ -15,7 +15,8 @@ import { cn } from "@/lib/utils"; interface ModalRepeaterTableConfigPanelProps { config: Partial; - onConfigChange: (config: Partial) => void; + onChange: (config: Partial) => void; + onConfigChange?: (config: Partial) => void; // 하위 호환성 } // 소스 컬럼 선택기 (동적 테이블별 컬럼 로드) @@ -124,8 +125,11 @@ function ReferenceColumnSelector({ export function ModalRepeaterTableConfigPanel({ config, + onChange, onConfigChange, }: ModalRepeaterTableConfigPanelProps) { + // 하위 호환성: onConfigChange가 있으면 사용, 없으면 onChange 사용 + const handleConfigChange = onConfigChange || onChange; // 초기 설정 정리: 계산 규칙과 컬럼 설정 동기화 const cleanupInitialConfig = (initialConfig: Partial): Partial => { // 계산 규칙이 없으면 모든 컬럼의 calculated 속성 제거 @@ -241,7 +245,7 @@ export function ModalRepeaterTableConfigPanel({ const updateConfig = (updates: Partial) => { const newConfig = { ...localConfig, ...updates }; setLocalConfig(newConfig); - onConfigChange(newConfig); + handleConfigChange(newConfig); }; const addSourceColumn = () => { 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..61f755a4 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -15,14 +15,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; -import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getSecondLevelMenus, getCategoryColumns, getCategoryColumnsByMenu, getCategoryValues } from "@/lib/api/tableCategoryValue"; import { CalculationBuilder } from "./CalculationBuilder"; export interface SelectedItemsDetailInputConfigPanelProps { config: SelectedItemsDetailInputConfig; onChange: (config: Partial) => void; - sourceTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 원본 테이블 컬럼 - targetTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 대상 테이블 컬럼 + sourceTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>; // 🆕 원본 테이블 컬럼 (inputType 추가) + targetTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>; // 🆕 대상 테이블 컬럼 (inputType, codeCategory 추가) allTables?: Array<{ tableName: string; displayName?: string }>; screenTableName?: string; // 🆕 현재 화면의 테이블명 (자동 설정용) onSourceTableChange?: (tableName: string) => void; // 🆕 원본 테이블 변경 콜백 @@ -50,6 +50,18 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(config.fieldGroups || []); + // 🆕 그룹 입력값을 위한 로컬 상태 (포커스 유지용) + const [localGroupInputs, setLocalGroupInputs] = useState>({}); + + // 🆕 필드 입력값을 위한 로컬 상태 (포커스 유지용) + const [localFieldInputs, setLocalFieldInputs] = useState>({}); + + // 🆕 표시 항목의 입력값을 위한 로컬 상태 (포커스 유지용) + const [localDisplayItemInputs, setLocalDisplayItemInputs] = useState>>({}); + + // 🆕 부모 데이터 매핑의 기본값 입력을 위한 로컬 상태 (포커스 유지용) + const [localMappingInputs, setLocalMappingInputs] = useState>({}); + // 🆕 그룹별 펼침/접힘 상태 const [expandedGroups, setExpandedGroups] = useState>({}); @@ -57,6 +69,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>({}); + // 🆕 카테고리 매핑 아코디언 펼침/접힘 상태 + const [expandedCategoryMappings, setExpandedCategoryMappings] = useState>({ + discountType: false, + roundingType: false, + roundingUnit: false, + }); + // 🆕 원본 테이블 선택 상태 const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false); const [sourceTableSearchValue, setSourceTableSearchValue] = useState(""); @@ -77,8 +96,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>>({}); // 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드) - const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState>([]); - const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState>([]); + const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); // 🆕 원본 테이블 컬럼 로드 useEffect(() => { @@ -99,6 +118,7 @@ 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; + }); + + // 🔧 표시 항목이 있는 그룹은 아코디언을 열린 상태로 초기화 + setExpandedDisplayItems(prev => { + const newExpanded = { ...prev }; + (config.fieldGroups || []).forEach(group => { + // 이미 상태가 있으면 유지, 없으면 displayItems가 있을 때만 열기 + if (!(group.id in newExpanded) && group.displayItems && group.displayItems.length > 0) { + newExpanded[group.id] = true; + } + }); + return newExpanded; + }); + }, [config.fieldGroups]); + // 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드 useEffect(() => { if (!localFields || localFields.length === 0) return; @@ -211,6 +265,36 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + const loadSavedMappingColumns = async () => { + if (!config.parentDataMapping || config.parentDataMapping.length === 0) { + console.log("📭 [부모 데이터 매핑] 매핑이 없습니다"); + return; + } + + console.log("🔍 [부모 데이터 매핑] 저장된 매핑 컬럼 자동 로드 시작:", config.parentDataMapping.length); + + for (let i = 0; i < config.parentDataMapping.length; i++) { + const mapping = config.parentDataMapping[i]; + + // 이미 로드된 컬럼이 있으면 스킵 + if (mappingSourceColumns[i] && mappingSourceColumns[i].length > 0) { + console.log(`⏭️ [매핑 ${i}] 이미 로드된 컬럼이 있음`); + continue; + } + + // 소스 테이블이 선택되어 있으면 컬럼 로드 + if (mapping.sourceTable) { + console.log(`📡 [매핑 ${i}] 소스 테이블 컬럼 자동 로드:`, mapping.sourceTable); + await loadMappingSourceColumns(mapping.sourceTable, i); + } + } + }; + + loadSavedMappingColumns(); + }, [config.parentDataMapping]); + // 2레벨 메뉴 목록 로드 useEffect(() => { const loadMenus = async () => { @@ -224,26 +308,39 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { - if (!config.targetTable) { - console.warn("⚠️ targetTable이 설정되지 않았습니다"); - return; - } + console.log("🔍 [handleMenuSelect] 시작", { menuObjid, fieldType }); - console.log("🔍 카테고리 목록 로드 시작", { targetTable: config.targetTable, menuObjid, fieldType }); + // 🔧 1단계: 아코디언 먼저 열기 (리렌더링 전에) + setExpandedCategoryMappings(prev => { + const newState = { ...prev, [fieldType]: true }; + console.log("🔄 [handleMenuSelect] 아코디언 열기:", newState); + return newState; + }); - const response = await getCategoryColumns(config.targetTable); + // 🔧 2단계: 메뉴별 카테고리 컬럼 API 호출 + const response = await getCategoryColumnsByMenu(menuObjid); - console.log("📥 getCategoryColumns 응답:", response); + console.log("📥 [handleMenuSelect] API 응답:", response); if (response.success && response.data) { - console.log("✅ 카테고리 컬럼 데이터:", response.data); - setCategoryColumns(prev => ({ ...prev, [fieldType]: response.data })); + console.log("✅ [handleMenuSelect] 카테고리 컬럼 데이터:", { + fieldType, + columns: response.data, + count: response.data.length + }); + + // 카테고리 컬럼 상태 업데이트 + setCategoryColumns(prev => { + const newState = { ...prev, [fieldType]: response.data }; + console.log("🔄 [handleMenuSelect] categoryColumns 업데이트:", newState); + return newState; + }); } else { - console.error("❌ 카테고리 컬럼 로드 실패:", response); + console.error("❌ [handleMenuSelect] 카테고리 컬럼 로드 실패:", response); } - // valueMapping 업데이트 - handleChange("autoCalculation", { + // 🔧 3단계: valueMapping 업데이트 (마지막에) + const newConfig = { ...config.autoCalculation, valueMapping: { ...config.autoCalculation.valueMapping, @@ -252,20 +349,50 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { - if (!config.targetTable) return; + console.log("🔍 [handleCategorySelect] 시작", { columnName, menuObjid, fieldType, targetTable: config.targetTable }); - const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid); - if (response.success && response.data) { - setCategoryValues(prev => ({ ...prev, [fieldType]: response.data })); + if (!config.targetTable) { + console.warn("⚠️ [handleCategorySelect] targetTable이 없습니다"); + return; } + const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid); + + console.log("📥 [handleCategorySelect] API 응답:", response); + + if (response.success && response.data) { + console.log("✅ [handleCategorySelect] 카테고리 값 데이터:", { + fieldType, + values: response.data, + count: response.data.length + }); + + setCategoryValues(prev => { + const newState = { ...prev, [fieldType]: response.data }; + console.log("🔄 [handleCategorySelect] categoryValues 업데이트:", newState); + return newState; + }); + } else { + console.error("❌ [handleCategorySelect] 카테고리 값 로드 실패:", response); + } + + // 🔧 카테고리 선택 시 아코디언 열기 (이미 열려있을 수도 있음) + setExpandedCategoryMappings(prev => { + const newState = { ...prev, [fieldType]: true }; + console.log("🔄 [handleCategorySelect] 아코디언 상태:", newState); + return newState; + }); + // valueMapping 업데이트 - handleChange("autoCalculation", { + const newConfig = { ...config.autoCalculation, valueMapping: { ...config.autoCalculation.valueMapping, @@ -274,9 +401,99 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + const loadSavedCategories = async () => { + console.log("🔍 [loadSavedCategories] useEffect 실행", { + hasTargetTable: !!config.targetTable, + hasAutoCalc: !!config.autoCalculation, + hasValueMapping: !!config.autoCalculation?.valueMapping + }); + + if (!config.targetTable || !config.autoCalculation?.valueMapping) { + console.warn("⚠️ [loadSavedCategories] targetTable 또는 valueMapping이 없어 종료"); + return; + } + + const savedMenus = (config.autoCalculation.valueMapping as any)?._selectedMenus; + const savedCategories = (config.autoCalculation.valueMapping as any)?._selectedCategories; + + console.log("🔄 [loadSavedCategories] 저장된 카테고리 설정 복원 시작:", { savedMenus, savedCategories }); + + // 각 필드 타입별로 저장된 카테고리 값 로드 + const fieldTypes: Array<"discountType" | "roundingType" | "roundingUnit"> = ["discountType", "roundingType", "roundingUnit"]; + + // 🔧 복원할 아코디언 상태 준비 + const newExpandedState: Record = {}; + + for (const fieldType of fieldTypes) { + const menuObjid = savedMenus?.[fieldType]; + const columnName = savedCategories?.[fieldType]; + + console.log(`🔍 [loadSavedCategories] ${fieldType} 처리`, { menuObjid, columnName }); + + // 🔧 메뉴만 선택된 경우에도 카테고리 컬럼 로드 + if (menuObjid) { + console.log(`✅ [loadSavedCategories] ${fieldType} 메뉴 발견, 카테고리 컬럼 로드 시작:`, { menuObjid }); + + // 🔧 메뉴가 선택되어 있으면 아코디언 열기 + newExpandedState[fieldType] = true; + + // 🔧 메뉴별 카테고리 컬럼 로드 (카테고리 선택 여부와 무관) + console.log(`📡 [loadSavedCategories] ${fieldType} 카테고리 컬럼 API 호출`, { menuObjid }); + const columnsResponse = await getCategoryColumnsByMenu(menuObjid); + console.log(`📥 [loadSavedCategories] ${fieldType} 컬럼 응답:`, columnsResponse); + + if (columnsResponse.success && columnsResponse.data) { + setCategoryColumns(prev => { + const newState = { ...prev, [fieldType]: columnsResponse.data }; + console.log(`🔄 [loadSavedCategories] ${fieldType} categoryColumns 업데이트:`, newState); + return newState; + }); + } else { + console.error(`❌ [loadSavedCategories] ${fieldType} 컬럼 로드 실패:`, columnsResponse); + } + + // 🔧 카테고리까지 선택된 경우에만 값 로드 + if (columnName) { + console.log(`📡 [loadSavedCategories] ${fieldType} 카테고리 값 API 호출`, { columnName }); + const valuesResponse = await getCategoryValues(config.targetTable, columnName, false, menuObjid); + console.log(`📥 [loadSavedCategories] ${fieldType} 값 응답:`, valuesResponse); + + if (valuesResponse.success && valuesResponse.data) { + console.log(`✅ [loadSavedCategories] ${fieldType} 카테고리 값:`, valuesResponse.data); + setCategoryValues(prev => { + const newState = { ...prev, [fieldType]: valuesResponse.data }; + console.log(`🔄 [loadSavedCategories] ${fieldType} categoryValues 업데이트:`, newState); + return newState; + }); + } else { + console.error(`❌ [loadSavedCategories] ${fieldType} 값 로드 실패:`, valuesResponse); + } + } + } + } + + // 🔧 저장된 설정이 있는 아코디언들 열기 + if (Object.keys(newExpandedState).length > 0) { + console.log("🔓 [loadSavedCategories] 아코디언 열기:", newExpandedState); + setExpandedCategoryMappings(prev => { + const finalState = { ...prev, ...newExpandedState }; + console.log("🔄 [loadSavedCategories] 최종 아코디언 상태:", finalState); + return finalState; + }); + } + }; + + loadSavedCategories(); + }, [config.targetTable, config.autoCalculation?.valueMapping]); + // 🆕 초기 로드 시 screenTableName을 targetTable로 자동 설정 React.useEffect(() => { if (screenTableName && !config.targetTable) { @@ -317,10 +534,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { + // 로컬 입력 상태에서도 제거 + setLocalFieldInputs(prev => { + const newInputs = { ...prev }; + delete newInputs[index]; + return newInputs; + }); handleFieldsChange(localFields.filter((_, i) => i !== index)); }; - // 필드 수정 + // 🆕 로컬 필드 입력 업데이트 (포커스 유지용) + const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => { + setLocalFieldInputs(prev => ({ + ...prev, + [index]: { + ...prev[index], + [field]: value + } + })); + }; + + // 🆕 실제 필드 데이터 업데이트 (onBlur 시 호출) + const handleFieldBlur = (index: number) => { + const localInput = localFieldInputs[index]; + if (localInput) { + const newFields = [...localFields]; + newFields[index] = { ...newFields[index], ...localInput }; + handleFieldsChange(newFields); + } + }; + + // 필드 수정 (Switch 같은 즉시 업데이트가 필요한 경우에만 사용) const updateField = (index: number, updates: Partial) => { const newFields = [...localFields]; newFields[index] = { ...newFields[index], ...updates }; @@ -343,6 +587,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 @@ -352,7 +603,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC g.id !== groupId)); }; + // 🆕 로컬 그룹 입력 업데이트 (포커스 유지용) + const updateGroupLocal = (groupId: string, field: 'id' | 'title' | 'description' | 'order', value: any) => { + setLocalGroupInputs(prev => ({ + ...prev, + [groupId]: { + ...prev[groupId], + [field]: value + } + })); + }; + + // 🆕 실제 그룹 데이터 업데이트 (onBlur 시 호출) + const handleGroupBlur = (groupId: string) => { + const localInput = localGroupInputs[groupId]; + if (localInput) { + const newGroups = localFieldGroups.map(g => + g.id === groupId ? { ...g, ...localInput } : g + ); + handleFieldGroupsChange(newGroups); + } + }; + const updateFieldGroup = (groupId: string, updates: Partial) => { + // 2. 실제 그룹 데이터 업데이트 (Switch 같은 즉시 업데이트가 필요한 경우에만 사용) const newGroups = localFieldGroups.map(g => g.id === groupId ? { ...g, ...updates } : g ); @@ -426,6 +700,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC ({ + ...prev, + [groupId]: true + })); + setLocalFieldGroups(updatedGroups); handleChange("fieldGroups", updatedGroups); }; @@ -755,8 +1035,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateField(index, { label: e.target.value })} + value={localFieldInputs[index]?.label !== undefined ? localFieldInputs[index].label : field.label} + onChange={(e) => updateFieldLocal(index, 'label', e.target.value)} + onBlur={() => handleFieldBlur(index)} placeholder="필드 라벨" className="h-6 w-full text-[10px] sm:h-7 sm:text-xs" /> @@ -780,8 +1061,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateField(index, { placeholder: e.target.value })} + value={localFieldInputs[index]?.placeholder !== undefined ? localFieldInputs[index].placeholder : (field.placeholder || "")} + onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)} + onBlur={() => handleFieldBlur(index)} placeholder="입력 안내" className="h-6 w-full text-[10px] sm:h-7 sm:text-xs" /> @@ -1036,8 +1318,9 @@ 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) => updateGroupLocal(group.id, 'id', e.target.value)} + onBlur={() => handleGroupBlur(group.id)} className="h-7 text-xs sm:h-8 sm:text-sm" placeholder="group_customer" /> @@ -1047,8 +1330,9 @@ 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) => updateGroupLocal(group.id, 'title', e.target.value)} + onBlur={() => handleGroupBlur(group.id)} className="h-7 text-xs sm:h-8 sm:text-sm" placeholder="거래처 정보" /> @@ -1058,8 +1342,9 @@ 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) => updateGroupLocal(group.id, 'description', e.target.value)} + onBlur={() => handleGroupBlur(group.id)} className="h-7 text-xs sm:h-8 sm:text-sm" placeholder="거래처 관련 정보를 입력합니다" /> @@ -1070,8 +1355,9 @@ 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) => updateGroupLocal(group.id, 'order', parseInt(e.target.value) || 0)} + onBlur={() => handleGroupBlur(group.id)} className="h-7 text-xs sm:h-8 sm:text-sm" min="0" /> @@ -1167,8 +1453,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { icon: e.target.value })} + value={ + localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined + ? localDisplayItemInputs[group.id][itemIndex].value + : item.icon || "" + } + onChange={(e) => { + const newValue = e.target.value; + setLocalDisplayItemInputs(prev => ({ + ...prev, + [group.id]: { + ...prev[group.id], + [itemIndex]: { + ...prev[group.id]?.[itemIndex], + value: newValue + } + } + })); + }} + onBlur={() => { + const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value; + if (localValue !== undefined) { + updateDisplayItemInGroup(group.id, itemIndex, { icon: localValue }); + } + }} placeholder="Building" className="h-6 text-[9px] sm:text-[10px]" /> @@ -1177,8 +1485,31 @@ 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 + } + } + })); + }} + onBlur={() => { + const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value; + if (localValue !== undefined) { + updateDisplayItemInGroup(group.id, itemIndex, { value: localValue }); + } + }} placeholder="| , / , -" className="h-6 text-[9px] sm:text-[10px]" /> @@ -1206,8 +1537,31 @@ 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 + } + } + })); + }} + onBlur={() => { + const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.label; + if (localValue !== undefined) { + updateDisplayItemInGroup(group.id, itemIndex, { label: localValue }); + } + }} placeholder="라벨 (예: 거래처:)" className="h-6 w-full text-[9px] sm:text-[10px]" /> @@ -1247,8 +1601,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: e.target.value })} + value={ + localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined + ? localDisplayItemInputs[group.id][itemIndex].value + : item.defaultValue || "" + } + onChange={(e) => { + const newValue = e.target.value; + setLocalDisplayItemInputs(prev => ({ + ...prev, + [group.id]: { + ...prev[group.id], + [itemIndex]: { + ...prev[group.id]?.[itemIndex], + value: newValue + } + } + })); + }} + onBlur={() => { + const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value; + if (localValue !== undefined) { + updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: localValue }); + } + }} placeholder="미입력" className="h-6 w-full text-[9px] sm:text-[10px]" /> @@ -1563,14 +1939,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC카테고리 값 매핑 {/* 할인 방식 매핑 */} - + setExpandedCategoryMappings(prev => ({ ...prev, discountType: open }))} + > @@ -1595,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 && ( @@ -1673,14 +2066,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {/* 반올림 방식 매핑 */} - + setExpandedCategoryMappings(prev => ({ ...prev, roundingType: open }))} + > @@ -1783,14 +2183,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {/* 반올림 단위 매핑 */} - + setExpandedCategoryMappings(prev => ({ ...prev, roundingUnit: open }))} + > @@ -2128,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 : "저장 테이블 컬럼 선택"} @@ -2141,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} + )}
@@ -2182,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" @@ -2200,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

-
-
{/* 사용 예시 */} @@ -2256,3 +2655,5 @@ export const SelectedItemsDetailInputConfigPanel: React.FC 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/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) => ( = ({ }, [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..e13e3d94 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -11,6 +11,8 @@ 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"; +import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; interface PresetFilter { id: string; @@ -43,6 +45,7 @@ interface TableSearchWidgetProps { export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) { const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions(); + const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인 // 높이 관리 context (실제 화면에서만 사용) let setWidgetHeight: @@ -62,7 +65,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 +233,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 +246,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 +311,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": @@ -400,14 +447,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table )} - {/* 동적 모드일 때만 설정 버튼들 표시 */} + {/* 동적 모드일 때만 설정 버튼들 표시 (미리보기에서는 비활성화) */} {filterMode === "dynamic" && ( <>