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 화면 미리보기 - {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 cc28be85..fb93f085 100644 --- a/frontend/components/ui/resizable-dialog.tsx +++ b/frontend/components/ui/resizable-dialog.tsx @@ -430,7 +430,7 @@ const ResizableDialogContent = React.forwardRef<
{children}
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/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/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..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" && ( <>