From 7120d5edc3c28592df2d914c314be5f3240023a5 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 6 Jan 2026 17:02:42 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity-search-input/EntitySearchInputComponent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx index 8bdd5758..70785171 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx @@ -52,7 +52,8 @@ export function EntitySearchInputComponent({ // 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서) const config = component?.componentConfig || {}; const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode; - const effectiveParentFieldId = parentFieldId || config.parentFieldId; + // cascadingParentField: ConfigPanel에서 저장되는 필드명 + const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId; const effectiveCascadingRole = config.cascadingRole; // "parent" | "child" | undefined // 부모 역할이면 연쇄관계 로직 적용 안함 (자식만 부모 값에 따라 필터링됨) From a2b701a4bfa95aca1aa3f57f705c6abbee4e5790 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 6 Jan 2026 17:29:41 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20initialData=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EC=B2=B4=EA=B3=84=20=EA=B5=AC=ED=98=84=20Interacti?= =?UTF-8?q?veScreenViewerDynamic:=20originalData=EB=A5=BC=20initialData?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80=20=EC=A0=84=EB=8B=AC=20DynamicCo?= =?UTF-8?q?mponentRenderer:=20initialData=20=EC=9A=B0=EC=84=A0=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20Condition?= =?UTF-8?q?alContainerComponent:=20initialData=20props=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=95=98=EC=9C=84=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=20ConditionalSectionViewer:=20initialData=20props=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=95=98=EC=9C=84=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=20types.ts:=20initialData=20=ED=83=80=EC=9E=85=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EC=B6=94=EA=B0=80=20=EC=88=98=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EA=B0=92=20=ED=91=9C=EC=8B=9C=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/InteractiveScreenViewerDynamic.tsx | 1 + frontend/lib/registry/DynamicComponentRenderer.tsx | 8 +++++--- .../ConditionalContainerComponent.tsx | 11 +++++++++++ .../ConditionalSectionViewer.tsx | 11 +++++++++++ .../components/conditional-container/types.ts | 3 +++ 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 4763507e..1dfdba14 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -365,6 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC = groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달 _groupedData: props.groupedData, // 하위 호환성 유지 // 🆕 UniversalFormModal용 initialData 전달 - // originalData가 비어있지 않으면 originalData 사용, 아니면 formData 사용 - // 생성 모드에서는 originalData가 빈 객체이므로 formData를 사용해야 함 - _initialData: (originalData && Object.keys(originalData).length > 0) ? originalData : formData, + // 우선순위: props.initialData > originalData > formData + // 조건부 컨테이너에서 전달된 initialData가 있으면 그것을 사용 + _initialData: props.initialData || ((originalData && Object.keys(originalData).length > 0) ? originalData : formData), _originalData: originalData, + // 🆕 initialData도 직접 전달 (조건부 컨테이너 → 내부 컴포넌트) + initialData: props.initialData, // 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용) parentTabId: props.parentTabId, parentTabsComponentId: props.parentTabsComponentId, diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index db3fde4c..e3d84d43 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -42,7 +42,16 @@ export function ConditionalContainerComponent({ className, groupedData, // 🆕 그룹 데이터 onSave, // 🆕 EditModal의 handleSave 콜백 + initialData, // 🆕 수정 모드: 초기 데이터 (발주일, 담당자, 메모 등) }: ConditionalContainerProps) { + // 🔍 디버그: initialData 수신 확인 + React.useEffect(() => { + console.log("[ConditionalContainer] initialData 수신:", { + hasInitialData: !!initialData, + initialDataKeys: initialData ? Object.keys(initialData) : [], + initialData, + }); + }, [initialData]); // 화면 컨텍스트 (데이터 제공자로 등록) const screenContext = useScreenContextOptional(); @@ -221,6 +230,7 @@ export function ConditionalContainerComponent({ onSave={onSave} controlField={controlField} selectedCondition={selectedValue} + initialData={initialData} /> ))} @@ -244,6 +254,7 @@ export function ConditionalContainerComponent({ onSave={onSave} controlField={controlField} selectedCondition={selectedValue} + initialData={initialData} /> ) : null ) diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index 59c82421..1338f40b 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -29,7 +29,17 @@ export function ConditionalSectionViewer({ onSave, // 🆕 EditModal의 handleSave 콜백 controlField, // 🆕 조건부 컨테이너의 제어 필드명 selectedCondition, // 🆕 현재 선택된 조건 값 + initialData, // 🆕 수정 모드: 초기 데이터 (발주일, 담당자, 메모 등) }: ConditionalSectionViewerProps) { + // 🔍 디버그: initialData 수신 확인 + React.useEffect(() => { + console.log("[ConditionalSectionViewer] initialData 수신:", { + sectionId, + hasInitialData: !!initialData, + initialDataKeys: initialData ? Object.keys(initialData) : [], + initialData, + }); + }, [initialData, sectionId]); const { userId, userName, user } = useAuth(); const [isLoading, setIsLoading] = useState(false); const [components, setComponents] = useState([]); @@ -191,6 +201,7 @@ export function ConditionalSectionViewer({ onFormDataChange={onFormDataChange} groupedData={groupedData} onSave={hasUniversalFormModal ? undefined : onSave} + initialData={initialData} /> ); diff --git a/frontend/lib/registry/components/conditional-container/types.ts b/frontend/lib/registry/components/conditional-container/types.ts index 284e0855..24ba2033 100644 --- a/frontend/lib/registry/components/conditional-container/types.ts +++ b/frontend/lib/registry/components/conditional-container/types.ts @@ -47,6 +47,7 @@ export interface ConditionalContainerProps { onFormDataChange?: (fieldName: string, value: any) => void; groupedData?: Record[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 + initialData?: Record; // 🆕 수정 모드: 초기 데이터 (발주일, 담당자, 메모 등) // 화면 편집기 관련 isDesignMode?: boolean; // 디자인 모드 여부 @@ -82,5 +83,7 @@ export interface ConditionalSectionViewerProps { // 🆕 조건부 컨테이너 정보 (자식 화면에 전달) controlField?: string; // 제어 필드명 (예: "inbound_type") selectedCondition?: string; // 현재 선택된 조건 값 (예: "PURCHASE_IN") + // 🆕 수정 모드: 초기 데이터 전달 (발주일, 담당자, 메모 등) + initialData?: Record; } From 12d3419b7ff99546f2c37755e2ccb29dbfcb60a4 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 6 Jan 2026 17:39:36 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 113 +++++++++++++++++- 1 file changed, 107 insertions(+), 6 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 24a93af8..389dd92d 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -6,6 +6,7 @@ import { WebType } from "@/types/common"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { codeCache } from "@/lib/caching/codeCache"; +import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { getFullImageUrl } from "@/lib/api/client"; import { Button } from "@/components/ui/button"; @@ -471,6 +472,7 @@ export const TableListComponent: React.FC = ({ } // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) + // 🆕 다중 값 지원: 셀 값이 "A,B,C" 형태일 때, 필터에서 "A"를 선택하면 해당 행도 표시 if (Object.keys(headerFilters).length > 0) { result = result.filter((row) => { return Object.entries(headerFilters).every(([columnName, values]) => { @@ -480,7 +482,16 @@ export const TableListComponent: React.FC = ({ const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; - return values.has(cellStr); + // 정확히 일치하는 경우 + if (values.has(cellStr)) return true; + + // 다중 값인 경우: 콤마로 분리해서 하나라도 포함되면 true + if (cellStr.includes(",")) { + const cellValues = cellStr.split(",").map(v => v.trim()); + return cellValues.some(v => values.has(v)); + } + + return false; }); }); } @@ -2248,12 +2259,18 @@ export const TableListComponent: React.FC = ({ // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) const startEditingRef = useRef<() => void>(() => {}); + // 🆕 카테고리 라벨 매핑 (API에서 가져온 것) + const [categoryLabelCache, setCategoryLabelCache] = useState>({}); + // 🆕 각 컬럼의 고유값 목록 계산 (라벨 포함) const columnUniqueValues = useMemo(() => { const result: Record> = {}; if (data.length === 0) return result; + // 🆕 전체 데이터에서 개별 값 -> 라벨 매핑 테이블 구축 (다중 값 처리용) + const globalLabelMap: Record> = {}; + (tableConfig.columns || []).forEach((column: { columnName: string }) => { if (column.columnName === "__checkbox__") return; @@ -2265,23 +2282,70 @@ export const TableListComponent: React.FC = ({ `${column.columnName}_value_label`, // 예: division_value_label ]; const valuesMap = new Map(); // value -> label + const singleValueLabelMap = new Map(); // 개별 값 -> 라벨 (다중값 처리용) + // 1차: 모든 데이터에서 개별 값 -> 라벨 매핑 수집 (단일값 + 다중값 모두) data.forEach((row) => { const val = row[mappedColumnName]; if (val !== null && val !== undefined && val !== "") { const valueStr = String(val); - // 라벨 컬럼 후보들 중 값이 있는 것 사용, 없으면 원본 값 사용 - let label = valueStr; + + // 라벨 컬럼에서 라벨 찾기 + let labelStr = ""; for (const labelCol of labelColumnCandidates) { if (row[labelCol] && row[labelCol] !== "") { - label = String(row[labelCol]); + labelStr = String(row[labelCol]); break; } } - valuesMap.set(valueStr, label); + + // 단일 값인 경우 + if (!valueStr.includes(",")) { + if (labelStr) { + singleValueLabelMap.set(valueStr, labelStr); + } + } else { + // 다중 값인 경우: 값과 라벨을 각각 분리해서 매핑 + const individualValues = valueStr.split(",").map(v => v.trim()); + const individualLabels = labelStr ? labelStr.split(",").map(l => l.trim()) : []; + + // 값과 라벨 개수가 같으면 1:1 매핑 + if (individualValues.length === individualLabels.length) { + individualValues.forEach((v, idx) => { + if (individualLabels[idx] && !singleValueLabelMap.has(v)) { + singleValueLabelMap.set(v, individualLabels[idx]); + } + }); + } + } } }); + // 2차: 모든 값 처리 (다중 값 포함) - 필터 목록용 + data.forEach((row) => { + const val = row[mappedColumnName]; + if (val !== null && val !== undefined && val !== "") { + const valueStr = String(val); + + // 콤마로 구분된 다중 값인지 확인 + if (valueStr.includes(",")) { + // 다중 값: 각각 분리해서 개별 라벨 찾기 + const individualValues = valueStr.split(",").map(v => v.trim()); + // 🆕 singleValueLabelMap → categoryLabelCache 순으로 라벨 찾기 + const individualLabels = individualValues.map(v => + singleValueLabelMap.get(v) || categoryLabelCache[v] || v + ); + valuesMap.set(valueStr, individualLabels.join(", ")); + } else { + // 단일 값: 매핑에서 찾거나 캐시에서 찾거나 원본 사용 + const label = singleValueLabelMap.get(valueStr) || categoryLabelCache[valueStr] || valueStr; + valuesMap.set(valueStr, label); + } + } + }); + + globalLabelMap[column.columnName] = singleValueLabelMap; + // value-label 쌍으로 저장하고 라벨 기준 정렬 result[column.columnName] = Array.from(valuesMap.entries()) .map(([value, label]) => ({ value, label })) @@ -2289,7 +2353,44 @@ export const TableListComponent: React.FC = ({ }); return result; - }, [data, tableConfig.columns, joinColumnMapping]); + }, [data, tableConfig.columns, joinColumnMapping, categoryLabelCache]); + + // 🆕 라벨을 못 찾은 CATEGORY_ 코드들을 API로 조회 + useEffect(() => { + const unlabeledCodes = new Set(); + + // columnUniqueValues에서 라벨이 코드 그대로인 항목 찾기 + Object.values(columnUniqueValues).forEach(items => { + items.forEach(item => { + // 라벨에 CATEGORY_가 포함되어 있으면 라벨을 못 찾은 것 + if (item.label.includes("CATEGORY_")) { + // 콤마로 분리해서 개별 코드 추출 + const codes = item.label.split(",").map(c => c.trim()); + codes.forEach(code => { + if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) { + unlabeledCodes.add(code); + } + }); + } + }); + }); + + if (unlabeledCodes.size === 0) return; + + // API로 라벨 조회 + const fetchLabels = async () => { + try { + const response = await getCategoryLabelsByCodes(Array.from(unlabeledCodes)); + if (response.success && response.data) { + setCategoryLabelCache(prev => ({ ...prev, ...response.data })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }; + + fetchLabels(); + }, [columnUniqueValues, categoryLabelCache]); // 🆕 헤더 필터 토글 const toggleHeaderFilter = useCallback((columnName: string, value: string) => { From ea848b97ee662a2cdad3b26c43ca128bf68baabf Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 6 Jan 2026 17:56:31 +0900 Subject: [PATCH 4/8] =?UTF-8?q?=EA=B2=80=EC=83=89=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ItemSelectionModal.tsx | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx index 1eca9fab..7bf7a81d 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx @@ -17,6 +17,7 @@ import { Search, Loader2 } from "lucide-react"; import { useEntitySearch } from "../entity-search-input/useEntitySearch"; import { ItemSelectionModalProps, ModalFilterConfig } from "./types"; import { apiClient } from "@/lib/api/client"; +import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue"; export function ItemSelectionModal({ open, @@ -99,13 +100,44 @@ export function ItemSelectionModal({ } } - // 정렬 후 옵션으로 변환 + // 🆕 CATEGORY_ 코드가 있는지 확인하고 라벨 조회 + const allCodes = new Set(); + for (const val of uniqueValues) { + // 콤마로 구분된 다중 값도 처리 + const codes = val.split(",").map(c => c.trim()); + codes.forEach(code => { + if (code.startsWith("CATEGORY_")) { + allCodes.add(code); + } + }); + } + + // CATEGORY_ 코드가 있으면 라벨 조회 + let labelMap: Record = {}; + if (allCodes.size > 0) { + try { + const labelResponse = await getCategoryLabelsByCodes(Array.from(allCodes)); + if (labelResponse.success && labelResponse.data) { + labelMap = labelResponse.data; + } + } catch (labelError) { + console.error("카테고리 라벨 조회 실패:", labelError); + } + } + + // 정렬 후 옵션으로 변환 (라벨 적용) const options = Array.from(uniqueValues) .sort() - .map((val) => ({ - value: val, - label: val, - })); + .map((val) => { + // 콤마로 구분된 다중 값 처리 + if (val.includes(",")) { + const codes = val.split(",").map(c => c.trim()); + const labels = codes.map(code => labelMap[code] || code); + return { value: val, label: labels.join(", ") }; + } + // 단일 값 + return { value: val, label: labelMap[val] || val }; + }); setCategoryOptions((prev) => ({ ...prev, From 126da9b46f481f6a4650965c1d91d6977252b2ef Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 7 Jan 2026 09:37:02 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=ED=95=98=EC=9D=B4=ED=81=90=EB=A7=88?= =?UTF-8?q?=EA=B7=B8=20=EC=A0=90=EA=B2=80=ED=95=AD=EB=AA=A9=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=ED=9B=84=20=EC=A3=BC=EA=B8=B0=EB=AA=85/=EC=A0=90?= =?UTF-8?q?=EA=B2=80=EB=B0=A9=EB=B2=95=EB=AA=85=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 389dd92d..74cea859 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -2392,6 +2392,44 @@ export const TableListComponent: React.FC = ({ fetchLabels(); }, [columnUniqueValues, categoryLabelCache]); + // 🆕 데이터에서 CATEGORY_ 코드를 찾아 라벨 미리 로드 (테이블 셀 렌더링용) + useEffect(() => { + if (data.length === 0) return; + + const categoryCodesToFetch = new Set(); + + // 모든 데이터 행에서 CATEGORY_ 코드 수집 + data.forEach((row) => { + Object.entries(row).forEach(([key, value]) => { + if (value && typeof value === "string") { + // 콤마로 구분된 다중 값도 처리 + const codes = value.split(",").map((v) => v.trim()); + codes.forEach((code) => { + if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) { + categoryCodesToFetch.add(code); + } + }); + } + }); + }); + + if (categoryCodesToFetch.size === 0) return; + + // API로 라벨 조회 + const fetchLabels = async () => { + try { + const response = await getCategoryLabelsByCodes(Array.from(categoryCodesToFetch)); + if (response.success && response.data && Object.keys(response.data).length > 0) { + setCategoryLabelCache((prev) => ({ ...prev, ...response.data })); + } + } catch (error) { + console.error("CATEGORY_ 라벨 조회 실패:", error); + } + }; + + fetchLabels(); + }, [data, categoryLabelCache]); + // 🆕 헤더 필터 토글 const toggleHeaderFilter = useCallback((columnName: string, value: string) => { setHeaderFilters((prev) => { @@ -4548,10 +4586,36 @@ export const TableListComponent: React.FC = ({ case "boolean": return value ? "예" : "아니오"; default: - return String(value); + // 🆕 CATEGORY_ 코드 자동 변환 (inputType이 category가 아니어도) + const strValue = String(value); + if (strValue.startsWith("CATEGORY_")) { + // rowData에서 _label 필드 찾기 + if (rowData) { + const labelFieldCandidates = [ + `${column.columnName}_label`, + `${column.columnName}_name`, + `${column.columnName}_value_label`, + ]; + for (const labelField of labelFieldCandidates) { + if (rowData[labelField] && rowData[labelField] !== "") { + return String(rowData[labelField]); + } + } + } + // categoryMappings에서 찾기 + const mapping = categoryMappings[column.columnName]; + if (mapping && mapping[strValue]) { + return mapping[strValue].label; + } + // categoryLabelCache에서 찾기 (필터용 캐시) + if (categoryLabelCache[strValue]) { + return categoryLabelCache[strValue]; + } + } + return strValue; } }, - [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings], + [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings, categoryLabelCache], ); // ======================================== From e308fd0cccde34e92c4915fe6291ef99bbf2400d Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 7 Jan 2026 09:55:19 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8A=94=EC=A7=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=98=EB=8A=94=20=EC=A0=9C=EC=96=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/nodeFlowExecutionService.ts | 163 +++++- .../node-editor/nodes/ConditionNode.tsx | 33 +- .../panels/properties/ConditionProperties.tsx | 545 +++++++++++++++--- frontend/types/node-editor.ts | 37 +- 4 files changed, 659 insertions(+), 119 deletions(-) diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 6f481198..616b4564 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -2707,28 +2707,48 @@ export class NodeFlowExecutionService { const trueData: any[] = []; const falseData: any[] = []; - inputData.forEach((item: any) => { - const results = conditions.map((condition: any) => { + // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기) + for (const item of inputData) { + const results: boolean[] = []; + + for (const condition of conditions) { const fieldValue = item[condition.field]; - let compareValue = condition.value; - if (condition.valueType === "field") { - compareValue = item[condition.value]; + // EXISTS 계열 연산자 처리 + if ( + condition.operator === "EXISTS_IN" || + condition.operator === "NOT_EXISTS_IN" + ) { + const existsResult = await this.evaluateExistsCondition( + fieldValue, + condition.operator, + condition.lookupTable, + condition.lookupField, + context.companyCode + ); + results.push(existsResult); logger.info( - `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - logger.info( - `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + // 일반 연산자 처리 + let compareValue = condition.value; + if (condition.valueType === "field") { + compareValue = item[condition.value]; + logger.info( + `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + ); + } else { + logger.info( + `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + ); + } + + results.push( + this.evaluateCondition(fieldValue, condition.operator, compareValue) ); } - - return this.evaluateCondition( - fieldValue, - condition.operator, - compareValue - ); - }); + } const result = logic === "OR" @@ -2740,7 +2760,7 @@ export class NodeFlowExecutionService { } else { falseData.push(item); } - }); + } logger.info( `🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)` @@ -2755,27 +2775,46 @@ export class NodeFlowExecutionService { } // 단일 객체인 경우 - const results = conditions.map((condition: any) => { + const results: boolean[] = []; + + for (const condition of conditions) { const fieldValue = inputData[condition.field]; - let compareValue = condition.value; - if (condition.valueType === "field") { - compareValue = inputData[condition.value]; + // EXISTS 계열 연산자 처리 + if ( + condition.operator === "EXISTS_IN" || + condition.operator === "NOT_EXISTS_IN" + ) { + const existsResult = await this.evaluateExistsCondition( + fieldValue, + condition.operator, + condition.lookupTable, + condition.lookupField, + context.companyCode + ); + results.push(existsResult); logger.info( - `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - logger.info( - `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + // 일반 연산자 처리 + let compareValue = condition.value; + if (condition.valueType === "field") { + compareValue = inputData[condition.value]; + logger.info( + `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + ); + } else { + logger.info( + `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + ); + } + + results.push( + this.evaluateCondition(fieldValue, condition.operator, compareValue) ); } - - return this.evaluateCondition( - fieldValue, - condition.operator, - compareValue - ); - }); + } const result = logic === "OR" @@ -2784,7 +2823,7 @@ export class NodeFlowExecutionService { logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`); - // ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요 + // 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요 // 조건 결과를 저장하고, 원본 데이터는 항상 반환 // 다음 노드에서 sourceHandle을 기반으로 필터링됨 return { @@ -2795,6 +2834,68 @@ export class NodeFlowExecutionService { }; } + /** + * EXISTS_IN / NOT_EXISTS_IN 조건 평가 + * 다른 테이블에 값이 존재하는지 확인 + */ + private static async evaluateExistsCondition( + fieldValue: any, + operator: string, + lookupTable: string, + lookupField: string, + companyCode?: string + ): Promise { + if (!lookupTable || !lookupField) { + logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다"); + return false; + } + + if (fieldValue === null || fieldValue === undefined || fieldValue === "") { + logger.info( + `⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환` + ); + // 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true + return operator === "NOT_EXISTS_IN"; + } + + try { + // 멀티테넌시: company_code 필터 적용 여부 확인 + // company_mng 테이블은 제외 + const hasCompanyCode = lookupTable !== "company_mng" && companyCode; + + let sql: string; + let params: any[]; + + if (hasCompanyCode) { + sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`; + params = [fieldValue, companyCode]; + } else { + sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`; + params = [fieldValue]; + } + + logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`); + + const result = await query(sql, params); + const existsInTable = result[0]?.exists_result === true; + + logger.info( + `🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}` + ); + + // EXISTS_IN: 존재하면 true + // NOT_EXISTS_IN: 존재하지 않으면 true + if (operator === "EXISTS_IN") { + return existsInTable; + } else { + return !existsInTable; + } + } catch (error: any) { + logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`); + return false; + } + } + /** * WHERE 절 생성 */ diff --git a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx index 5418fcab..4cf5e32d 100644 --- a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx +++ b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx @@ -22,6 +22,13 @@ const OPERATOR_LABELS: Record = { NOT_IN: "NOT IN", IS_NULL: "NULL", IS_NOT_NULL: "NOT NULL", + EXISTS_IN: "EXISTS IN", + NOT_EXISTS_IN: "NOT EXISTS IN", +}; + +// EXISTS 계열 연산자인지 확인 +const isExistsOperator = (operator: string): boolean => { + return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN"; }; export const ConditionNode = memo(({ data, selected }: NodeProps) => { @@ -54,15 +61,31 @@ export const ConditionNode = memo(({ data, selected }: NodeProps 0 && (
{data.logic}
)} -
+
{condition.field} - + {OPERATOR_LABELS[condition.operator] || condition.operator} - {condition.value !== null && condition.value !== undefined && ( - - {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)} + {/* EXISTS 연산자인 경우 테이블.필드 표시 */} + {isExistsOperator(condition.operator) ? ( + + {(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."} + {(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`} + ) : ( + // 일반 연산자인 경우 값 표시 + condition.value !== null && + condition.value !== undefined && ( + + {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)} + + ) )}
diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx index 87f7f771..a2d060d4 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx @@ -4,14 +4,18 @@ * 조건 분기 노드 속성 편집 */ -import { useEffect, useState } from "react"; -import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useState, useCallback } from "react"; +import { Plus, Trash2, Database, Search, Check, ChevronsUpDown } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; -import type { ConditionNodeData } from "@/types/node-editor"; +import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; // 필드 정의 interface FieldDefinition { @@ -20,6 +24,19 @@ interface FieldDefinition { type?: string; } +// 테이블 정보 +interface TableInfo { + tableName: string; + tableLabel: string; +} + +// 테이블 컬럼 정보 +interface ColumnInfo { + columnName: string; + columnLabel: string; + dataType: string; +} + interface ConditionPropertiesProps { nodeId: string; data: ConditionNodeData; @@ -38,8 +55,194 @@ const OPERATORS = [ { value: "NOT_IN", label: "NOT IN" }, { value: "IS_NULL", label: "NULL" }, { value: "IS_NOT_NULL", label: "NOT NULL" }, + { value: "EXISTS_IN", label: "다른 테이블에 존재함" }, + { value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" }, ] as const; +// EXISTS 계열 연산자인지 확인 +const isExistsOperator = (operator: string): boolean => { + return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN"; +}; + +// 테이블 선택용 검색 가능한 Combobox +function TableCombobox({ + tables, + value, + onSelect, + placeholder = "테이블 검색...", +}: { + tables: TableInfo[]; + value: string; + onSelect: (value: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + + const selectedTable = tables.find((t) => t.tableName === value); + + return ( + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + onSelect(table.tableName); + setOpen(false); + }} + className="text-xs" + > + +
+ {table.tableLabel} + {table.tableName} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 컬럼 선택용 검색 가능한 Combobox +function ColumnCombobox({ + columns, + value, + onSelect, + placeholder = "컬럼 검색...", +}: { + columns: ColumnInfo[]; + value: string; + onSelect: (value: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + + const selectedColumn = columns.find((c) => c.columnName === value); + + return ( + + + + + + + + + 컬럼을 찾을 수 없습니다. + + {columns.map((col) => ( + { + onSelect(col.columnName); + setOpen(false); + }} + className="text-xs" + > + + {col.columnLabel} + ({col.columnName}) + + ))} + + + + + + ); +} + +// 컬럼 선택 섹션 (자동 로드 포함) +function ColumnSelectSection({ + lookupTable, + lookupField, + tableColumnsCache, + loadingColumns, + loadTableColumns, + onSelect, +}: { + lookupTable: string; + lookupField: string; + tableColumnsCache: Record; + loadingColumns: Record; + loadTableColumns: (tableName: string) => Promise; + onSelect: (value: string) => void; +}) { + // 캐시에 없고 로딩 중이 아니면 자동으로 로드 + useEffect(() => { + if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) { + loadTableColumns(lookupTable); + } + }, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]); + + const isLoading = loadingColumns[lookupTable]; + const columns = tableColumnsCache[lookupTable]; + + return ( +
+ + {isLoading ? ( +
+ 컬럼 목록 로딩 중... +
+ ) : columns && columns.length > 0 ? ( + + ) : ( +
+ 컬럼 목록을 로드할 수 없습니다 +
+ )} +
+ ); +} + export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) { const { updateNode, nodes, edges } = useFlowEditorStore(); @@ -48,6 +251,12 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND"); const [availableFields, setAvailableFields] = useState([]); + // EXISTS 연산자용 상태 + const [allTables, setAllTables] = useState([]); + const [tableColumnsCache, setTableColumnsCache] = useState>({}); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState>({}); + // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || "조건 분기"); @@ -55,6 +264,100 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) setLogic(data.logic || "AND"); }, [data]); + // 전체 테이블 목록 로드 (EXISTS 연산자용) + useEffect(() => { + const loadAllTables = async () => { + // 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵 + if (allTables.length > 0) return; + + // EXISTS 연산자가 하나라도 있으면 테이블 목록 로드 + const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator)); + if (!hasExistsOperator) return; + + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName, + tableLabel: t.tableLabel || t.tableName, + })) + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + + loadAllTables(); + }, [conditions, allTables.length]); + + // 테이블 컬럼 로드 함수 + const loadTableColumns = useCallback( + async (tableName: string): Promise => { + // 캐시에 있으면 반환 + if (tableColumnsCache[tableName]) { + return tableColumnsCache[tableName]; + } + + // 이미 로딩 중이면 스킵 + if (loadingColumns[tableName]) { + return []; + } + + // 로딩 상태 설정 + setLoadingColumns((prev) => ({ ...prev, [tableName]: true })); + + try { + // getColumnList 반환: { success, data: { columns, total, ... } } + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data && response.data.columns) { + const columns = response.data.columns.map((c: any) => ({ + columnName: c.columnName, + columnLabel: c.columnLabel || c.columnName, + dataType: c.dataType, + })); + setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns })); + console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개"); + return columns; + } else { + console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response); + } + } catch (error) { + console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error); + } finally { + setLoadingColumns((prev) => ({ ...prev, [tableName]: false })); + } + return []; + }, + [tableColumnsCache, loadingColumns] + ); + + // EXISTS 연산자 선택 시 테이블 목록 강제 로드 + const ensureTablesLoaded = useCallback(async () => { + if (allTables.length > 0) return; + + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName, + tableLabel: t.tableLabel || t.tableName, + })) + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }, [allTables.length]); + // 🔥 연결된 소스 노드의 필드를 재귀적으로 수집 useEffect(() => { const getAllSourceFields = (currentNodeId: string, visited: Set = new Set()): FieldDefinition[] => { @@ -170,15 +473,18 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }, [nodeId, nodes, edges]); const handleAddCondition = () => { - setConditions([ - ...conditions, - { - field: "", - operator: "EQUALS", - value: "", - valueType: "static", // "static" (고정값) 또는 "field" (필드 참조) - }, - ]); + const newCondition = { + field: "", + operator: "EQUALS" as ConditionOperator, + value: "", + valueType: "static" as "static" | "field", + // EXISTS 연산자용 필드는 초기값 없음 + lookupTable: undefined, + lookupTableLabel: undefined, + lookupField: undefined, + lookupFieldLabel: undefined, + }; + setConditions([...conditions, newCondition]); }; const handleRemoveCondition = (index: number) => { @@ -196,9 +502,50 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }); }; - const handleConditionChange = (index: number, field: string, value: any) => { + const handleConditionChange = async (index: number, field: string, value: any) => { const newConditions = [...conditions]; newConditions[index] = { ...newConditions[index], [field]: value }; + + // EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화 + if (field === "operator" && isExistsOperator(value)) { + await ensureTablesLoaded(); + // EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화 + newConditions[index].value = ""; + newConditions[index].valueType = undefined; + } + + // EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화 + if (field === "operator" && !isExistsOperator(value)) { + newConditions[index].lookupTable = undefined; + newConditions[index].lookupTableLabel = undefined; + newConditions[index].lookupField = undefined; + newConditions[index].lookupFieldLabel = undefined; + } + + // lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정 + if (field === "lookupTable" && value) { + const tableInfo = allTables.find((t) => t.tableName === value); + if (tableInfo) { + newConditions[index].lookupTableLabel = tableInfo.tableLabel; + } + // 테이블 변경 시 필드 초기화 + newConditions[index].lookupField = undefined; + newConditions[index].lookupFieldLabel = undefined; + // 컬럼 목록 미리 로드 + await loadTableColumns(value); + } + + // lookupField 변경 시 라벨 설정 + if (field === "lookupField" && value) { + const tableName = newConditions[index].lookupTable; + if (tableName && tableColumnsCache[tableName]) { + const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value); + if (columnInfo) { + newConditions[index].lookupFieldLabel = columnInfo.columnLabel; + } + } + } + setConditions(newConditions); updateNode(nodeId, { conditions: newConditions, @@ -329,64 +676,114 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) - {condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && ( + {/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */} + {isExistsOperator(condition.operator) && ( <>
- - + + {loadingTables ? ( +
+ 테이블 목록 로딩 중... +
+ ) : allTables.length > 0 ? ( + handleConditionChange(index, "lookupTable", value)} + placeholder="테이블 검색..." + /> + ) : ( +
+ 테이블 목록을 로드할 수 없습니다 +
+ )}
-
- - {(condition as any).valueType === "field" ? ( - // 필드 참조: 드롭다운으로 선택 - availableFields.length > 0 ? ( - - ) : ( -
- 소스 노드를 연결하세요 -
- ) - ) : ( - // 고정값: 직접 입력 - handleConditionChange(index, "value", e.target.value)} - placeholder="비교할 값" - className="mt-1 h-8 text-xs" - /> - )} + {(condition as any).lookupTable && ( + handleConditionChange(index, "lookupField", value)} + /> + )} + +
+ {condition.operator === "EXISTS_IN" + ? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE` + : `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
)} + + {/* 일반 연산자인 경우: 기존 비교값 UI */} + {condition.operator !== "IS_NULL" && + condition.operator !== "IS_NOT_NULL" && + !isExistsOperator(condition.operator) && ( + <> +
+ + +
+ +
+ + {(condition as any).valueType === "field" ? ( + // 필드 참조: 드롭다운으로 선택 + availableFields.length > 0 ? ( + + ) : ( +
+ 소스 노드를 연결하세요 +
+ ) + ) : ( + // 고정값: 직접 입력 + handleConditionChange(index, "value", e.target.value)} + placeholder="비교할 값" + className="mt-1 h-8 text-xs" + /> + )} +
+ + )}
))} @@ -402,20 +799,28 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {/* 안내 */}
- 🔌 소스 노드 연결: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다. + 소스 노드 연결: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
- 🔄 비교 값 타입:
고정값: 직접 입력한 값과 비교 (예: age > 30) -
필드 참조: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량) + 비교 값 타입:
+ - 고정값: 직접 입력한 값과 비교 (예: age > 30) +
- 필드 참조: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량) +
+
+ 테이블 존재 여부 검사:
+ - 다른 테이블에 존재함: 값이 다른 테이블에 있으면 TRUE +
- 다른 테이블에 존재하지 않음: 값이 다른 테이블에 없으면 TRUE +
+ (예: 품명이 품목정보 테이블에 없으면 자동 등록)
- 💡 AND: 모든 조건이 참이어야 TRUE 출력 + AND: 모든 조건이 참이어야 TRUE 출력
- 💡 OR: 하나라도 참이면 TRUE 출력 + OR: 하나라도 참이면 TRUE 출력
- ⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다. + TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts index 55c8f67e..6eb1bb1c 100644 --- a/frontend/types/node-editor.ts +++ b/frontend/types/node-editor.ts @@ -95,24 +95,35 @@ export interface RestAPISourceNodeData { displayName?: string; } +// 조건 연산자 타입 +export type ConditionOperator = + | "EQUALS" + | "NOT_EQUALS" + | "GREATER_THAN" + | "LESS_THAN" + | "GREATER_THAN_OR_EQUAL" + | "LESS_THAN_OR_EQUAL" + | "LIKE" + | "NOT_LIKE" + | "IN" + | "NOT_IN" + | "IS_NULL" + | "IS_NOT_NULL" + | "EXISTS_IN" // 다른 테이블에 존재함 + | "NOT_EXISTS_IN"; // 다른 테이블에 존재하지 않음 + // 조건 분기 노드 export interface ConditionNodeData { conditions: Array<{ field: string; - operator: - | "EQUALS" - | "NOT_EQUALS" - | "GREATER_THAN" - | "LESS_THAN" - | "GREATER_THAN_OR_EQUAL" - | "LESS_THAN_OR_EQUAL" - | "LIKE" - | "NOT_LIKE" - | "IN" - | "NOT_IN" - | "IS_NULL" - | "IS_NOT_NULL"; + operator: ConditionOperator; value: any; + valueType?: "static" | "field"; // 비교 값 타입 + // EXISTS_IN / NOT_EXISTS_IN 전용 필드 + lookupTable?: string; // 조회할 테이블명 + lookupTableLabel?: string; // 조회할 테이블 라벨 + lookupField?: string; // 조회할 테이블의 비교 필드 + lookupFieldLabel?: string; // 조회할 테이블의 비교 필드 라벨 }>; logic: "AND" | "OR"; displayName?: string; From c6ff839e54fedfe910aa1820e0d18934a9ce8e36 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 7 Jan 2026 10:05:32 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=EC=9E=85=EA=B3=A0=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EA=B3=A0=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=9E=AC=EA=B3=A0=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20stock=5Fid=20=EC=A0=84=EB=8B=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/nodeFlowExecutionService.ts | 27 ++++-- frontend/lib/utils/buttonActions.ts | 82 +++++++++++++++++-- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 6f481198..40eada6e 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -2282,6 +2282,7 @@ export class NodeFlowExecutionService { UPDATE ${targetTable} SET ${setClauses.join(", ")} WHERE ${updateWhereConditions} + RETURNING * `; logger.info(`🔄 UPDATE 실행:`, { @@ -2292,8 +2293,14 @@ export class NodeFlowExecutionService { values: updateValues, }); - await txClient.query(updateSql, updateValues); + const updateResult = await txClient.query(updateSql, updateValues); updatedCount++; + + // 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능) + if (updateResult.rows && updateResult.rows[0]) { + Object.assign(data, updateResult.rows[0]); + logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`); + } } else { // 3-B. 없으면 INSERT const columns: string[] = []; @@ -2340,6 +2347,7 @@ export class NodeFlowExecutionService { const insertSql = ` INSERT INTO ${targetTable} (${columns.join(", ")}) VALUES (${placeholders}) + RETURNING * `; logger.info(`➕ INSERT 실행:`, { @@ -2348,8 +2356,14 @@ export class NodeFlowExecutionService { conflictKeyValues, }); - await txClient.query(insertSql, values); + const insertResult = await txClient.query(insertSql, values); insertedCount++; + + // 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능) + if (insertResult.rows && insertResult.rows[0]) { + Object.assign(data, insertResult.rows[0]); + logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`); + } } } @@ -2357,11 +2371,10 @@ export class NodeFlowExecutionService { `✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건` ); - return { - insertedCount, - updatedCount, - totalCount: insertedCount + updatedCount, - }; + // 🔥 다음 노드에 전달할 데이터 반환 + // dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음 + // 카운트 정보도 함께 반환하여 기존 호환성 유지 + return dataArray; }; // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 6daf17e9..29ff8f3f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -304,6 +304,9 @@ export interface ButtonActionContext { selectedLeftData?: Record; refreshRightPanel?: () => void; }; + + // 🆕 저장된 데이터 (저장 후 제어 실행 시 플로우에 전달) + savedData?: any; } /** @@ -1251,7 +1254,49 @@ export class ButtonActionExecutor { // 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우) if (config.enableDataflowControl && config.dataflowConfig) { console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig); - await this.executeAfterSaveControl(config, context); + + // 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우) + // 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨 + const formData: Record = (saveResult.data || context.formData || {}) as Record; + let parsedSectionData: any[] = []; + + // comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기 + const compFieldKey = Object.keys(formData).find(key => + key.startsWith("comp_") && typeof formData[key] === "string" + ); + + if (compFieldKey) { + try { + const sectionData = JSON.parse(formData[compFieldKey]); + if (Array.isArray(sectionData) && sectionData.length > 0) { + // 공통 필드와 섹션 데이터 병합 + parsedSectionData = sectionData.map((item: any) => { + // 섹션 데이터에서 불필요한 내부 필드 제거 + const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item; + // 공통 필드(comp_ 필드 제외) + 섹션 아이템 병합 + const commonFields: Record = {}; + Object.keys(formData).forEach(key => { + if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) { + commonFields[key] = formData[key]; + } + }); + return { ...commonFields, ...cleanItem }; + }); + console.log(`📦 [handleSave] 테이블 섹션 데이터 파싱 완료: ${parsedSectionData.length}건`, parsedSectionData[0]); + } + } catch (parseError) { + console.warn("⚠️ [handleSave] 테이블 섹션 데이터 파싱 실패:", parseError); + } + } + + // 저장된 데이터를 context에 추가하여 플로우에 전달 + const contextWithSavedData = { + ...context, + savedData: formData, + // 파싱된 섹션 데이터가 있으면 selectedRowsData로 전달 + selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData, + }; + await this.executeAfterSaveControl(config, contextWithSavedData); } } else { throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); @@ -3643,8 +3688,20 @@ export class ButtonActionExecutor { // 노드 플로우 실행 API const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); - // 데이터 소스 준비 - const sourceData: any = context.formData || {}; + // 데이터 소스 준비: context-data 모드는 배열을 기대함 + // 우선순위: selectedRowsData > savedData > formData + // - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함) + // - savedData: 저장 API 응답 데이터 + // - formData: 폼에 입력된 데이터 + let sourceData: any[]; + if (context.selectedRowsData && context.selectedRowsData.length > 0) { + sourceData = context.selectedRowsData; + console.log("📦 [다중제어] selectedRowsData 사용:", sourceData.length, "건"); + } else { + const savedData = context.savedData || context.formData || {}; + sourceData = Array.isArray(savedData) ? savedData : [savedData]; + console.log("📦 [다중제어] savedData/formData 사용:", sourceData.length, "건"); + } let allSuccess = true; const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = []; @@ -3751,8 +3808,20 @@ export class ButtonActionExecutor { // 노드 플로우 실행 API 호출 const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); - // 데이터 소스 준비 - const sourceData: any = context.formData || {}; + // 데이터 소스 준비: context-data 모드는 배열을 기대함 + // 우선순위: selectedRowsData > savedData > formData + // - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함) + // - savedData: 저장 API 응답 데이터 + // - formData: 폼에 입력된 데이터 + let sourceData: any[]; + if (context.selectedRowsData && context.selectedRowsData.length > 0) { + sourceData = context.selectedRowsData; + console.log("📦 [단일제어] selectedRowsData 사용:", sourceData.length, "건"); + } else { + const savedData = context.savedData || context.formData || {}; + sourceData = Array.isArray(savedData) ? savedData : [savedData]; + console.log("📦 [단일제어] savedData/formData 사용:", sourceData.length, "건"); + } // repeat-screen-modal 데이터가 있으면 병합 const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) => @@ -3765,7 +3834,8 @@ export class ButtonActionExecutor { console.log("📦 노드 플로우에 전달할 데이터:", { flowId, dataSourceType: controlDataSource, - sourceData, + sourceDataCount: sourceData.length, + sourceDataSample: sourceData[0], }); const result = await executeNodeFlow(flowId, { From 7c165a724ef51346dd44b8fa763ec901d82ddf12 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 7 Jan 2026 10:24:01 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=EC=B6=9C=EA=B3=A0=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=ED=99=94=20=EC=BB=AC=EB=9F=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20UI=20=EC=B6=94=EA=B0=80=20ButtonConfigPanel:=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=95=A1=EC=85=98=EC=97=90=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=ED=99=94=20=EC=BB=AC=EB=9F=BC=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?=EC=98=81=EB=AC=B8/=ED=95=9C=EA=B8=80=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90)=20ScreenSplitPanel/EmbeddedScreen:=20groupe?= =?UTF-8?q?dData=20prop=20=EC=A0=84=EB=8B=AC=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20buttonActions:=20RepeaterFieldGroup=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=EA=B3=B5=ED=86=B5=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=9A=B0=EC=84=A0=20=EC=A0=81=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=91=ED=95=A9=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen-embedding/EmbeddedScreen.tsx | 5 +- .../screen-embedding/ScreenSplitPanel.tsx | 7 +- .../config-panels/ButtonConfigPanel.tsx | 136 ++++++++++++++++++ .../ScreenSplitPanelRenderer.tsx | 3 +- frontend/lib/utils/buttonActions.ts | 5 +- 5 files changed, 149 insertions(+), 7 deletions(-) diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index 3bfb7a77..0b32830e 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -27,13 +27,14 @@ interface EmbeddedScreenProps { onSelectionChanged?: (selectedRows: any[]) => void; position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right) initialFormData?: Record; // 🆕 수정 모드에서 전달되는 초기 데이터 + groupedData?: Record[]; // 🆕 그룹 데이터 (수정 모드에서 원본 데이터 추적용) } /** * 임베드된 화면 컴포넌트 */ export const EmbeddedScreen = forwardRef( - ({ embedding, onSelectionChanged, position, initialFormData }, ref) => { + ({ embedding, onSelectionChanged, position, initialFormData, groupedData }, ref) => { const [layout, setLayout] = useState([]); const [selectedRows, setSelectedRows] = useState([]); const [loading, setLoading] = useState(true); @@ -430,6 +431,8 @@ export const EmbeddedScreen = forwardRef ); diff --git a/frontend/components/screen-embedding/ScreenSplitPanel.tsx b/frontend/components/screen-embedding/ScreenSplitPanel.tsx index f457e851..2f30a4ec 100644 --- a/frontend/components/screen-embedding/ScreenSplitPanel.tsx +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -17,13 +17,14 @@ interface ScreenSplitPanelProps { screenId?: number; config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable) initialFormData?: Record; // 🆕 수정 모드에서 전달되는 초기 데이터 + groupedData?: Record[]; // 🆕 그룹 데이터 (수정 모드에서 원본 데이터 추적용) } /** * 분할 패널 컴포넌트 * 순수하게 화면 분할 기능만 제공합니다. */ -export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) { +export function ScreenSplitPanel({ screenId, config, initialFormData, groupedData }: ScreenSplitPanelProps) { // config에서 splitRatio 추출 (기본값 50) const configSplitRatio = config?.splitRatio ?? 50; @@ -117,7 +118,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp {/* 좌측 패널 */}
{hasLeftScreen ? ( - + ) : (

좌측 화면을 선택하세요

@@ -157,7 +158,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp {/* 우측 패널 */}
{hasRightScreen ? ( - + ) : (

우측 화면을 선택하세요

diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 417ea4ff..a97d78b3 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -60,6 +60,7 @@ export const ButtonConfigPanel: React.FC = ({ editModalTitle: String(config.action?.editModalTitle || ""), editModalDescription: String(config.action?.editModalDescription || ""), targetUrl: String(config.action?.targetUrl || ""), + groupByColumn: String(config.action?.groupByColumns?.[0] || ""), }); const [screens, setScreens] = useState([]); @@ -97,6 +98,11 @@ export const ButtonConfigPanel: React.FC = ({ const [modalTargetColumns, setModalTargetColumns] = useState>([]); const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState>({}); const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState>({}); + + // 🆕 그룹화 컬럼 선택용 상태 + const [currentTableColumns, setCurrentTableColumns] = useState>([]); + const [groupByColumnOpen, setGroupByColumnOpen] = useState(false); + const [groupByColumnSearch, setGroupByColumnSearch] = useState(""); const [modalSourceSearch, setModalSourceSearch] = useState>({}); const [modalTargetSearch, setModalTargetSearch] = useState>({}); @@ -130,6 +136,7 @@ export const ButtonConfigPanel: React.FC = ({ editModalTitle: String(latestAction.editModalTitle || ""), editModalDescription: String(latestAction.editModalDescription || ""), targetUrl: String(latestAction.targetUrl || ""), + groupByColumn: String(latestAction.groupByColumns?.[0] || ""), }); // 🆕 제목 블록 초기화 @@ -327,6 +334,35 @@ export const ButtonConfigPanel: React.FC = ({ loadColumns(); }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); + // 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용) + useEffect(() => { + if (!currentTableName) return; + + const loadCurrentTableColumns = async () => { + try { + const response = await apiClient.get(`/table-management/tables/${currentTableName}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + + if (Array.isArray(columnData)) { + const columns = columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); + setCurrentTableColumns(columns); + console.log(`✅ 현재 테이블 ${currentTableName} 컬럼 로드 성공:`, columns.length, "개"); + } + } + } catch (error) { + console.error("현재 테이블 컬럼 로드 실패:", error); + } + }; + + loadCurrentTableColumns(); + }, [currentTableName]); + // 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드 useEffect(() => { const actionType = config.action?.type; @@ -1529,6 +1565,106 @@ export const ButtonConfigPanel: React.FC = ({
)} + +
+ + + + + + +
+
+ + setGroupByColumnSearch(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {currentTableColumns.length === 0 ? ( +
+ {currentTableName ? "컬럼을 불러오는 중..." : "테이블이 설정되지 않았습니다"} +
+ ) : ( + <> + {/* 선택 해제 옵션 */} +
{ + setLocalInputs((prev) => ({ ...prev, groupByColumn: "" })); + onUpdateProperty("componentConfig.action.groupByColumns", undefined); + setGroupByColumnOpen(false); + setGroupByColumnSearch(""); + }} + > + + 선택 안 함 +
+ {/* 컬럼 목록 */} + {currentTableColumns + .filter((col) => { + if (!groupByColumnSearch) return true; + const search = groupByColumnSearch.toLowerCase(); + return ( + col.name.toLowerCase().includes(search) || + col.label.toLowerCase().includes(search) + ); + }) + .map((col) => ( +
{ + setLocalInputs((prev) => ({ ...prev, groupByColumn: col.name })); + onUpdateProperty("componentConfig.action.groupByColumns", [col.name]); + setGroupByColumnOpen(false); + setGroupByColumnSearch(""); + }} + > + +
+ {col.name} + {col.label !== col.name && ( + {col.label} + )} +
+
+ ))} + + )} +
+
+
+
+

+ 여러 행을 하나의 그룹으로 묶어서 수정할 때 사용합니다 +

+
)} diff --git a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx index 5dc1830c..adeb9e20 100644 --- a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx +++ b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx @@ -66,7 +66,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer { }; render() { - const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any; + const { component, style = {}, componentConfig, config, screenId, formData, groupedData } = this.props as any; // componentConfig 또는 config 또는 component.componentConfig 사용 const finalConfig = componentConfig || config || component?.componentConfig || {}; @@ -77,6 +77,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer { screenId={screenId || finalConfig.screenId} config={finalConfig} initialFormData={formData} // 🆕 수정 데이터 전달 + groupedData={groupedData} // 🆕 그룹 데이터 전달 (수정 모드에서 원본 데이터 추적용) />
); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 6daf17e9..6c5d5d36 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1036,10 +1036,11 @@ export class ButtonActionExecutor { } // 🆕 공통 필드 병합 + 사용자 정보 추가 - // 공통 필드를 먼저 넣고, 개별 항목 데이터로 덮어씀 (개별 항목이 우선) + // 개별 항목 데이터를 먼저 넣고, 공통 필드로 덮어씀 (공통 필드가 우선) + // 이유: 사용자가 공통 필드(출고상태 등)를 변경하면 모든 항목에 적용되어야 함 const dataWithMeta: Record = { - ...commonFields, // 범용 폼 모달의 공통 필드 (order_no, manager_id 등) ...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터 + ...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선! created_by: context.userId, updated_by: context.userId, company_code: context.companyCode,