From b74cb94191bb358795daee6e44ef462e53e27558 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 19 Nov 2025 10:03:38 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/screenManagementService.ts | 189 ++++++-- .../CalculationBuilder.tsx | 429 ++++++++++++++++++ .../SelectedItemsDetailInputComponent.tsx | 40 +- .../SelectedItemsDetailInputConfigPanel.tsx | 338 +++++++++----- .../selected-items-detail-input/types.ts | 60 ++- frontend/lib/utils/buttonActions.ts | 11 + 6 files changed, 887 insertions(+), 180 deletions(-) create mode 100644 frontend/lib/registry/components/selected-items-detail-input/CalculationBuilder.tsx diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index c2036cbd..daf0ea26 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2101,55 +2101,109 @@ export class ScreenManagementService { } /** - * 화면에 연결된 모달 화면들을 자동 감지 - * 버튼 컴포넌트의 popup 액션에서 targetScreenId를 추출 + * 화면에 연결된 모달/화면들을 재귀적으로 자동 감지 + * - 버튼 컴포넌트: popup/modal/edit/openModalWithData 액션의 targetScreenId + * - 조건부 컨테이너: sections[].screenId (조건별 화면 할당) + * - 중첩된 화면들도 모두 감지 (재귀) */ async detectLinkedModalScreens( screenId: number ): Promise<{ screenId: number; screenName: string; screenCode: string }[]> { - // 화면의 모든 레이아웃 조회 - const layouts = await query( - `SELECT layout_id, properties - FROM screen_layouts - WHERE screen_id = $1 - AND component_type = 'component' - AND properties IS NOT NULL`, - [screenId] - ); + console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`); + + const allLinkedScreenIds = new Set(); + const visited = new Set(); // 무한 루프 방지 + const queue: number[] = [screenId]; // BFS 큐 - const linkedScreenIds = new Set(); + // BFS로 연결된 모든 화면 탐색 + while (queue.length > 0) { + const currentScreenId = queue.shift()!; + + // 이미 방문한 화면은 스킵 (순환 참조 방지) + if (visited.has(currentScreenId)) { + console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`); + continue; + } + + visited.add(currentScreenId); + console.log(`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`); - // 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인 - for (const layout of layouts) { - try { - const properties = layout.properties; - - // 버튼 컴포넌트인지 확인 - if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) { - const action = properties?.componentConfig?.action; + // 현재 화면의 모든 레이아웃 조회 + const layouts = await query( + `SELECT layout_id, properties + FROM screen_layouts + WHERE screen_id = $1 + AND component_type = 'component' + AND properties IS NOT NULL`, + [currentScreenId] + ); + + console.log(` 📦 레이아웃 개수: ${layouts.length}`); + + // 각 레이아웃에서 연결된 화면 ID 확인 + for (const layout of layouts) { + try { + const properties = layout.properties; - // popup, modal, edit 액션이고 targetScreenId가 있는 경우 - // edit 액션도 수정 폼 모달을 열기 때문에 포함 - if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) { - const targetScreenId = parseInt(action.targetScreenId); - if (!isNaN(targetScreenId)) { - linkedScreenIds.add(targetScreenId); - console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`); + // 1. 버튼 컴포넌트의 액션 확인 + if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) { + const action = properties?.componentConfig?.action; + + const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"]; + if (modalActionTypes.includes(action?.type) && action?.targetScreenId) { + const targetScreenId = parseInt(action.targetScreenId); + if (!isNaN(targetScreenId) && targetScreenId !== currentScreenId) { + // 메인 화면이 아닌 경우에만 추가 + if (targetScreenId !== screenId) { + allLinkedScreenIds.add(targetScreenId); + } + // 아직 방문하지 않은 화면이면 큐에 추가 + if (!visited.has(targetScreenId)) { + queue.push(targetScreenId); + console.log(` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`); + } + } } } + + // 2. conditional-container 컴포넌트의 sections 확인 + if (properties?.componentType === "conditional-container") { + const sections = properties?.componentConfig?.sections || []; + + for (const section of sections) { + if (section?.screenId) { + const sectionScreenId = parseInt(section.screenId); + if (!isNaN(sectionScreenId) && sectionScreenId !== currentScreenId) { + // 메인 화면이 아닌 경우에만 추가 + if (sectionScreenId !== screenId) { + allLinkedScreenIds.add(sectionScreenId); + } + // 아직 방문하지 않은 화면이면 큐에 추가 + if (!visited.has(sectionScreenId)) { + queue.push(sectionScreenId); + console.log(` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`); + } + } + } + } + } + } catch (error) { + console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error); } - } catch (error) { - // JSON 파싱 오류 등은 무시하고 계속 진행 - console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error); } } + console.log(`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}개`); + console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`); + console.log(` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`); + // 감지된 화면 ID들의 정보 조회 - if (linkedScreenIds.size === 0) { + if (allLinkedScreenIds.size === 0) { + console.log(`ℹ️ 연결된 화면이 없습니다.`); return []; } - const screenIds = Array.from(linkedScreenIds); + const screenIds = Array.from(allLinkedScreenIds); const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", "); const linkedScreens = await query( @@ -2161,6 +2215,11 @@ export class ScreenManagementService { screenIds ); + console.log(`\n📋 최종 감지된 화면 목록:`); + linkedScreens.forEach((s: any) => { + console.log(` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`); + }); + return linkedScreens.map((s) => ({ screenId: s.screen_id, screenName: s.screen_name, @@ -2430,23 +2489,23 @@ export class ScreenManagementService { for (const layout of layouts) { try { const properties = layout.properties; + let needsUpdate = false; - // 버튼 컴포넌트인지 확인 + // 1. 버튼 컴포넌트의 targetScreenId 업데이트 if ( properties?.componentType === "button" || properties?.componentType?.startsWith("button-") ) { const action = properties?.componentConfig?.action; - // targetScreenId가 있는 액션 (popup, modal, edit) + // targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData) + const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"]; if ( - (action?.type === "popup" || - action?.type === "modal" || - action?.type === "edit") && + modalActionTypes.includes(action?.type) && action?.targetScreenId ) { const oldScreenId = parseInt(action.targetScreenId); - console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`); + console.log(`🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`); // 매핑에 있으면 업데이트 if (screenIdMapping.has(oldScreenId)) { @@ -2456,31 +2515,63 @@ export class ScreenManagementService { // properties 업데이트 properties.componentConfig.action.targetScreenId = newScreenId.toString(); + needsUpdate = true; - // 데이터베이스 업데이트 - await query( - `UPDATE screen_layouts - SET properties = $1 - WHERE layout_id = $2`, - [JSON.stringify(properties), layout.layout_id] - ); - - updateCount++; console.log( - `🔗 버튼 targetScreenId 업데이트: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})` + `🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})` ); } else { console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); } } } + + // 2. conditional-container 컴포넌트의 sections[].screenId 업데이트 + if (properties?.componentType === "conditional-container") { + const sections = properties?.componentConfig?.sections || []; + + for (const section of sections) { + if (section?.screenId) { + const oldScreenId = parseInt(section.screenId); + console.log(`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`); + + // 매핑에 있으면 업데이트 + if (screenIdMapping.has(oldScreenId)) { + const newScreenId = screenIdMapping.get(oldScreenId)!; + console.log(`✅ 매핑 발견: ${oldScreenId} → ${newScreenId}`); + + // section.screenId 업데이트 + section.screenId = newScreenId; + needsUpdate = true; + + console.log( + `🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})` + ); + } else { + console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); + } + } + } + } + + // 3. 업데이트가 필요한 경우 DB 저장 + if (needsUpdate) { + await query( + `UPDATE screen_layouts + SET properties = $1 + WHERE layout_id = $2`, + [JSON.stringify(properties), layout.layout_id] + ); + updateCount++; + console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`); + } } catch (error) { console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error); // 개별 레이아웃 오류는 무시하고 계속 진행 } } - console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`); + console.log(`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`); return updateCount; } } diff --git a/frontend/lib/registry/components/selected-items-detail-input/CalculationBuilder.tsx b/frontend/lib/registry/components/selected-items-detail-input/CalculationBuilder.tsx new file mode 100644 index 00000000..92ac2e42 --- /dev/null +++ b/frontend/lib/registry/components/selected-items-detail-input/CalculationBuilder.tsx @@ -0,0 +1,429 @@ +"use client"; + +import React, { useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Plus, X, Calculator } from "lucide-react"; +import { CalculationNode, CalculationStep, AdditionalFieldDefinition } from "./types"; + +interface CalculationBuilderProps { + steps: CalculationStep[]; + availableFields: AdditionalFieldDefinition[]; + onChange: (steps: CalculationStep[]) => void; +} + +export const CalculationBuilder: React.FC = ({ + steps, + availableFields, + onChange, +}) => { + const [previewValues, setPreviewValues] = useState>({}); + + // 새 단계 추가 + const addStep = () => { + const newStep: CalculationStep = { + id: `step_${Date.now()}`, + label: `단계 ${steps.length + 1}`, + expression: { + type: "field", + fieldName: "", + }, + }; + onChange([...steps, newStep]); + }; + + // 단계 삭제 + const removeStep = (stepId: string) => { + onChange(steps.filter((s) => s.id !== stepId)); + }; + + // 단계 업데이트 + const updateStep = (stepId: string, updates: Partial) => { + onChange( + steps.map((s) => (s.id === stepId ? { ...s, ...updates } : s)) + ); + }; + + // 간단한 표현식 렌더링 + const renderSimpleExpression = (step: CalculationStep) => { + return ( +
+
+ {/* 왼쪽 항 */} + + + {step.expression.type === "constant" && ( + { + updateStep(step.id, { + expression: { + ...step.expression, + value: parseFloat(e.target.value) || 0, + }, + }); + }} + className="h-8 w-24 text-xs" + placeholder="값" + /> + )} +
+ + {/* 연산 추가 버튼 */} + {step.expression.type !== "operation" && ( + + )} + + {/* 연산식 */} + {step.expression.type === "operation" && ( +
+ {renderOperationExpression(step)} +
+ )} +
+ ); + }; + + // 연산식 렌더링 + const renderOperationExpression = (step: CalculationStep) => { + if (step.expression.type !== "operation") return null; + + return ( +
+ {/* 왼쪽 항 */} +
+ {renderNodeLabel(step.expression.left)} +
+ + {/* 연산자 */} + + + {/* 오른쪽 항 */} + + + {step.expression.right?.type === "constant" && ( + { + updateStep(step.id, { + expression: { + ...step.expression, + right: { + ...step.expression.right!, + value: parseFloat(e.target.value) || 0, + }, + }, + }); + }} + className="h-7 w-24 text-xs" + placeholder="값" + /> + )} +
+ ); + }; + + // 노드 라벨 표시 + const renderNodeLabel = (node?: CalculationNode): string => { + if (!node) return ""; + + switch (node.type) { + case "field": + const field = availableFields.find((f) => f.name === node.fieldName); + return field?.label || node.fieldName || "필드"; + case "constant": + return String(node.value || 0); + case "previous": + return "이전 결과"; + case "operation": + const left = renderNodeLabel(node.left); + const right = renderNodeLabel(node.right); + const op = node.operator === "*" ? "×" : node.operator === "/" ? "÷" : node.operator; + return `(${left} ${op} ${right})`; + default: + return ""; + } + }; + + // 함수 적용 UI + const renderFunctionStep = (step: CalculationStep) => { + if (step.expression.type !== "function") return null; + + return ( +
+ + + {(step.expression.functionName === "round" || + step.expression.functionName === "floor" || + step.expression.functionName === "ceil") && ( + <> + 단위: + + + )} +
+ ); + }; + + return ( +
+
+ + +
+ + {steps.length === 0 ? ( + + + +

+ 계산 단계를 추가하여 계산식을 만드세요 +

+
+
+ ) : ( +
+ {steps.map((step, idx) => ( + + +
+ + {step.label || `단계 ${idx + 1}`} + +
+ updateStep(step.id, { label: e.target.value })} + placeholder={`단계 ${idx + 1}`} + className="h-6 w-24 text-xs" + /> + +
+
+
+ + {step.expression.type === "function" + ? renderFunctionStep(step) + : renderSimpleExpression(step)} + + {/* 함수 적용 버튼 */} + {step.expression.type !== "function" && ( + + )} + +
+ ))} +
+ )} + + {/* 미리보기 */} + {steps.length > 0 && ( + + +
+ 계산식: +
+ {steps.map((step, idx) => ( +
+ {idx + 1}. {renderNodeLabel(step.expression)} +
+ ))} +
+
+
+
+ )} +
+ ); +}; + diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 904fc4be..04403872 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -258,10 +258,32 @@ export const SelectedItemsDetailInputComponent: React.FC { const handleSaveRequest = () => { + // component.id를 문자열로 안전하게 변환 + const componentKey = String(component.id || "selected_items"); + + console.log("🔔 [SelectedItemsDetailInput] beforeFormSave 이벤트 수신!", { + itemsCount: items.length, + hasOnFormDataChange: !!onFormDataChange, + componentId: component.id, + componentIdType: typeof component.id, + componentKey, + }); + if (items.length > 0 && onFormDataChange) { - const dataToSave = { [component.id || "selected_items"]: items }; - console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", dataToSave); + const dataToSave = { [componentKey]: items }; + console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", { + key: componentKey, + itemsCount: items.length, + fullData: dataToSave, + firstItem: items[0], + }); onFormDataChange(dataToSave); + } else { + console.warn("⚠️ [SelectedItemsDetailInput] 저장 데이터 전달 실패:", { + hasItems: items.length > 0, + hasCallback: !!onFormDataChange, + itemsLength: items.length, + }); } }; @@ -342,6 +364,14 @@ export const SelectedItemsDetailInputComponent: React.FC { + console.log("📝 [handleFieldChange] 필드 값 변경:", { + itemId, + groupId, + entryId, + fieldName, + value, + }); + setItems((prevItems) => { return prevItems.map((item) => { if (item.id !== itemId) return item; @@ -357,6 +387,12 @@ export const SelectedItemsDetailInputComponent: React.FC
- { - if (checked) { - handleChange("autoCalculation", { - targetField: "calculated_price", - inputFields: { - basePrice: "current_unit_price", - discountType: "discount_type", - discountValue: "discount_value", - roundingType: "rounding_type", - roundingUnit: "rounding_unit_value", - }, - calculationType: "price", - }); - } else { - handleChange("autoCalculation", undefined); - } - }} - /> + { + if (checked) { + handleChange("autoCalculation", { + targetField: "", + mode: "template", + inputFields: { + basePrice: "", + discountType: "", + discountValue: "", + roundingType: "", + roundingUnit: "", + }, + calculationType: "price", + valueMapping: {}, + calculationSteps: [], + }); + } else { + handleChange("autoCalculation", undefined); + } + }} + />
{config.autoCalculation && (
+ {/* 계산 모드 선택 */}
- - handleChange("autoCalculation", { - ...config.autoCalculation, - targetField: e.target.value, - })} - placeholder="calculated_price" - className="h-7 text-xs" - /> + +
-
- - handleChange("autoCalculation", { - ...config.autoCalculation, - inputFields: { - ...config.autoCalculation.inputFields, - basePrice: e.target.value, - }, - })} - placeholder="current_unit_price" - className="h-7 text-xs" - /> -
+ {/* 템플릿 모드 */} + {config.autoCalculation.mode === "template" && ( + <> + {/* 계산 필드 선택 */} +
+ + +
+ {/* 계산 결과 필드 */} +
+ + +
-
-
- - handleChange("autoCalculation", { - ...config.autoCalculation, - inputFields: { - ...config.autoCalculation.inputFields, - discountType: e.target.value, - }, - })} - placeholder="discount_type" - className="h-7 text-xs" - /> -
+ {/* 기준 단가 필드 */} +
+ + +
-
- - handleChange("autoCalculation", { - ...config.autoCalculation, - inputFields: { - ...config.autoCalculation.inputFields, - discountValue: e.target.value, - }, - })} - placeholder="discount_value" - className="h-7 text-xs" - /> +
+ {/* 할인 방식 필드 */} +
+ + +
+ + {/* 할인값 필드 */} +
+ + +
+
+ +
+ {/* 반올림 방식 필드 */} +
+ + +
+ + {/* 반올림 단위 필드 */} +
+ + +
+
-
-
- - handleChange("autoCalculation", { - ...config.autoCalculation, - inputFields: { - ...config.autoCalculation.inputFields, - roundingType: e.target.value, - }, - })} - placeholder="rounding_type" - className="h-7 text-xs" - /> -
- -
- - handleChange("autoCalculation", { - ...config.autoCalculation, - inputFields: { - ...config.autoCalculation.inputFields, - roundingUnit: e.target.value, - }, - })} - placeholder="rounding_unit_value" - className="h-7 text-xs" - /> -
-
- -

- 💡 위 필드명들이 추가 입력 필드에 있어야 자동 계산이 작동합니다 -

- {/* 카테고리 값 매핑 */}
@@ -1591,6 +1683,24 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
+ + )} + + {/* 커스텀 모드 (계산식 빌더) */} + {config.autoCalculation.mode === "custom" && ( +
+ { + handleChange("autoCalculation", { + ...config.autoCalculation, + calculationSteps: steps, + }); + }} + /> +
+ )}
)}
diff --git a/frontend/lib/registry/components/selected-items-detail-input/types.ts b/frontend/lib/registry/components/selected-items-detail-input/types.ts index 0e6120c6..4fd0ba10 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -58,37 +58,67 @@ export interface FieldGroup { displayItems?: DisplayItem[]; } +/** + * 🆕 계산식 노드 타입 + */ +export type CalculationNodeType = "field" | "constant" | "operation" | "function" | "previous"; + +export interface CalculationNode { + type: CalculationNodeType; + // field: 필드명 + fieldName?: string; + // constant: 상수값 + value?: number; + // operation: 연산 + operator?: "+" | "-" | "*" | "/" | "%" | "^"; + left?: CalculationNode; + right?: CalculationNode; + // function: 함수 + functionName?: "round" | "floor" | "ceil" | "abs" | "max" | "min"; + params?: CalculationNode[]; +} + +/** + * 🆕 계산 단계 + */ +export interface CalculationStep { + id: string; + label: string; + expression: CalculationNode; +} + /** * 🆕 자동 계산 설정 */ export interface AutoCalculationConfig { /** 계산 대상 필드명 (예: calculated_price) */ targetField: string; - /** 계산에 사용할 입력 필드들 */ - inputFields: { - basePrice: string; // 기본 단가 필드명 - discountType: string; // 할인 방식 필드명 - discountValue: string; // 할인값 필드명 - roundingType: string; // 반올림 방식 필드명 - roundingUnit: string; // 반올림 단위 필드명 + /** 🆕 계산 방식 */ + mode: "template" | "custom"; + + /** 템플릿 모드 (기존 방식) */ + inputFields?: { + basePrice: string; + discountType: string; + discountValue: string; + roundingType: string; + roundingUnit: string; }; - /** 계산 함수 타입 */ - calculationType: "price" | "custom"; - /** 🆕 카테고리 값 → 연산 매핑 */ + calculationType?: "price" | "custom"; valueMapping?: { - /** 할인 방식 매핑 */ discountType?: { - [valueCode: string]: "none" | "rate" | "amount"; // 예: { "CATEGORY_544740": "rate" } + [valueCode: string]: "none" | "rate" | "amount"; }; - /** 반올림 방식 매핑 */ roundingType?: { [valueCode: string]: "none" | "round" | "floor" | "ceil"; }; - /** 반올림 단위 매핑 (숫자로 변환) */ roundingUnit?: { - [valueCode: string]: number; // 예: { "10": 10, "100": 100 } + [valueCode: string]: number; }; }; + + /** 커스텀 모드 (계산식 빌더) */ + calculationSteps?: CalculationStep[]; } /** diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 615aedf9..84cc3626 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -230,6 +230,16 @@ export class ButtonActionExecutor { const selectedItemsKeys = Object.keys(context.formData).filter(key => { const value = context.formData[key]; + console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, { + isArray: Array.isArray(value), + length: Array.isArray(value) ? value.length : 0, + firstItem: Array.isArray(value) && value.length > 0 ? { + keys: Object.keys(value[0] || {}), + hasOriginalData: !!value[0]?.originalData, + hasFieldGroups: !!value[0]?.fieldGroups, + actualValue: value[0], + } : null + }); return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups; }); @@ -238,6 +248,7 @@ export class ButtonActionExecutor { return await this.handleBatchSave(config, context, selectedItemsKeys); } else { console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행"); + console.log("⚠️ [handleSave] formData 전체 내용:", context.formData); } // 폼 유효성 검사 -- 2.43.0 From f4e4ee13e2edaa695bdeac64e3f28af930993c37 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 19 Nov 2025 13:22:49 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EB=B6=80=EB=AA=A8=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A7=A4=ED=95=91=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(=EC=84=A0=ED=83=9D=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=9E=85=EB=A0=A5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 여러 테이블(거래처, 품목 등)에서 데이터를 가져와 자동 매핑 가능 - 각 매핑마다 소스 테이블, 원본 필드, 저장 필드를 독립적으로 설정 - 검색 가능한 Combobox로 테이블 및 컬럼 선택 UX 개선 - 소스 테이블 선택 시 해당 테이블의 컬럼 자동 로드 - 라벨, 컬럼명, 데이터 타입으로 검색 가능 - 세로 레이아웃으로 가독성 향상 기술적 변경사항: - ParentDataMapping 인터페이스 추가 (sourceTable, sourceField, targetField) - buttonActions.ts의 handleBatchSave에서 소스 테이블 기반 데이터 소스 자동 판단 - tableManagementApi.getColumnList() 사용하여 테이블 컬럼 동적 로드 - Command + Popover 조합으로 검색 가능한 Select 구현 - 각 매핑별 독립적인 컬럼 상태 관리 (mappingSourceColumns) --- .../controllers/screenManagementController.ts | 3 +- .../src/services/screenManagementService.ts | 13 +- frontend/components/screen/ScreenList.tsx | 160 +++++++-- .../SelectedItemsDetailInputComponent.tsx | 23 +- .../SelectedItemsDetailInputConfigPanel.tsx | 320 +++++++++++++++++- .../selected-items-detail-input/types.ts | 21 ++ frontend/lib/utils/buttonActions.ts | 205 +++++++++-- 7 files changed, 689 insertions(+), 56 deletions(-) diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index dd589fdd..be3a16a3 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -23,7 +23,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => { const result = await screenManagementService.getScreensByCompany( targetCompanyCode, parseInt(page as string), - parseInt(size as string) + parseInt(size as string), + searchTerm as string // 검색어 전달 ); res.json({ diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index daf0ea26..6c3a3430 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -98,7 +98,8 @@ export class ScreenManagementService { async getScreensByCompany( companyCode: string, page: number = 1, - size: number = 20 + size: number = 20, + searchTerm?: string // 검색어 추가 ): Promise> { const offset = (page - 1) * size; @@ -111,6 +112,16 @@ export class ScreenManagementService { params.push(companyCode); } + // 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색) + if (searchTerm && searchTerm.trim() !== "") { + whereConditions.push(`( + screen_name ILIKE $${params.length + 1} OR + screen_code ILIKE $${params.length + 1} OR + table_name ILIKE $${params.length + 1} + )`); + params.push(`%${searchTerm.trim()}%`); + } + const whereSQL = whereConditions.join(" AND "); // 페이징 쿼리 (Raw Query) diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 63ec2210..116fa0df 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Checkbox } from "@/components/ui/checkbox"; +import { useAuth } from "@/hooks/useAuth"; import { DropdownMenu, DropdownMenuContent, @@ -66,17 +67,31 @@ type DeletedScreenDefinition = ScreenDefinition & { }; export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) { + const { user } = useAuth(); + const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*"; + const [activeTab, setActiveTab] = useState("active"); const [screens, setScreens] = useState([]); const [deletedScreens, setDeletedScreens] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(true); // 초기 로딩 + const [isSearching, setIsSearching] = useState(false); // 검색 중 로딩 (포커스 유지) const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const [selectedCompanyCode, setSelectedCompanyCode] = useState("all"); + const [companies, setCompanies] = useState([]); + const [loadingCompanies, setLoadingCompanies] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCopyOpen, setIsCopyOpen] = useState(false); const [screenToCopy, setScreenToCopy] = useState(null); + // 검색어 디바운스를 위한 타이머 ref + const debounceTimer = useRef(null); + + // 첫 로딩 여부를 추적 (한 번만 true) + const isFirstLoad = useRef(true); + // 삭제 관련 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [screenToDelete, setScreenToDelete] = useState(null); @@ -119,14 +134,75 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const [isLoadingPreview, setIsLoadingPreview] = useState(false); const [previewFormData, setPreviewFormData] = useState>({}); - // 화면 목록 로드 (실제 API) + // 최고 관리자인 경우 회사 목록 로드 + useEffect(() => { + if (isSuperAdmin) { + loadCompanies(); + } + }, [isSuperAdmin]); + + const loadCompanies = async () => { + try { + setLoadingCompanies(true); + const { apiClient } = await import("@/lib/api/client"); // named export + const response = await apiClient.get("/admin/companies"); + const data = response.data.data || response.data || []; + setCompanies(data.map((c: any) => ({ + companyCode: c.company_code || c.companyCode, + companyName: c.company_name || c.companyName, + }))); + } catch (error) { + console.error("회사 목록 조회 실패:", error); + } finally { + setLoadingCompanies(false); + } + }; + + // 검색어 디바운스 처리 (150ms 지연 - 빠른 응답) + useEffect(() => { + // 이전 타이머 취소 + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + // 새 타이머 설정 + debounceTimer.current = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 150); + + // 클린업 + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, [searchTerm]); + + // 화면 목록 로드 (실제 API) - debouncedSearchTerm 사용 useEffect(() => { let abort = false; const load = async () => { try { - setLoading(true); + // 첫 로딩인 경우에만 loading=true, 그 외에는 isSearching=true + if (isFirstLoad.current) { + setLoading(true); + isFirstLoad.current = false; // 첫 로딩 완료 표시 + } else { + setIsSearching(true); + } + if (activeTab === "active") { - const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm }); + const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm }; + + // 최고 관리자이고 특정 회사를 선택한 경우 + if (isSuperAdmin && selectedCompanyCode !== "all") { + params.companyCode = selectedCompanyCode; + } + + console.log("🔍 화면 목록 API 호출:", params); // 디버깅용 + const resp = await screenApi.getScreens(params); + console.log("✅ 화면 목록 응답:", resp); // 디버깅용 + if (abort) return; setScreens(resp.data || []); setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); @@ -137,7 +213,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); } } catch (e) { - // console.error("화면 목록 조회 실패", e); + console.error("화면 목록 조회 실패", e); if (activeTab === "active") { setScreens([]); } else { @@ -145,28 +221,38 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } setTotalPages(1); } finally { - if (!abort) setLoading(false); + if (!abort) { + setLoading(false); + setIsSearching(false); + } } }; load(); return () => { abort = true; }; - }, [currentPage, searchTerm, activeTab]); + }, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]); const filteredScreens = screens; // 서버 필터 기준 사용 // 화면 목록 다시 로드 const reloadScreens = async () => { try { - setLoading(true); - const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm }); + setIsSearching(true); + const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm }; + + // 최고 관리자이고 특정 회사를 선택한 경우 + if (isSuperAdmin && selectedCompanyCode !== "all") { + params.companyCode = selectedCompanyCode; + } + + const resp = await screenApi.getScreens(params); setScreens(resp.data || []); setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); } catch (e) { - // console.error("화면 목록 조회 실패", e); + console.error("화면 목록 조회 실패", e); } finally { - setLoading(false); + setIsSearching(false); } }; @@ -405,18 +491,48 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{/* 검색 및 필터 */}
-
-
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - disabled={activeTab === "trash"} - /> +
+ {/* 최고 관리자 전용: 회사 필터 */} + {isSuperAdmin && ( +
+ +
+ )} + + {/* 검색 입력 */} +
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + disabled={activeTab === "trash"} + /> + {/* 검색 중 인디케이터 */} + {isSearching && ( +
+
+
+ )} +
+
+ {/* 🆕 부모 데이터 매핑 */} +
+
+ + +
+ +

+ 이전 화면(거래처 선택 등)에서 넘어온 데이터를 자동으로 매핑합니다. +

+ +
+ {(config.parentDataMapping || []).map((mapping, index) => ( + +
+ {/* 소스 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables.map((table) => ( + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { + ...updated[index], + sourceTable: currentValue, + sourceField: "", // 테이블 변경 시 필드 초기화 + }; + handleChange("parentDataMapping", updated); + + // 테이블 선택 시 컬럼 로드 + if (currentValue) { + loadMappingSourceColumns(currentValue, index); + } + }} + className="text-xs" + > + + {table.displayName || table.tableName} + + ))} + + + + + +

+ 품목, 거래처, 사용자 등 데이터를 가져올 테이블을 선택하세요 +

+
+ + {/* 원본 필드 */} +
+ + + + + + + + + + {!mapping.sourceTable ? ( + 소스 테이블을 먼저 선택하세요 + ) : !mappingSourceColumns[index] || mappingSourceColumns[index].length === 0 ? ( + 컬럼 로딩 중... + ) : ( + <> + 컬럼을 찾을 수 없습니다. + + {mappingSourceColumns[index].map((col) => { + const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase(); + return ( + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], sourceField: col.columnName }; + handleChange("parentDataMapping", updated); + }} + className="text-xs" + > + +
+ {col.columnLabel || col.columnName} + {col.dataType && ( + + {col.dataType} + + )} +
+
+ ); + })} +
+ + )} +
+
+
+
+
+ + {/* 저장 필드 (현재 화면 테이블 컬럼) */} +
+ + + + + + + + + + {targetTableColumns.length === 0 ? ( + 저장 테이블을 먼저 선택하세요 + ) : ( + <> + 컬럼을 찾을 수 없습니다. + + {targetTableColumns.map((col) => { + const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase(); + return ( + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], targetField: col.columnName }; + handleChange("parentDataMapping", updated); + }} + className="text-xs" + > + +
+ {col.columnLabel || col.columnName} + {col.dataType && ( + {col.dataType} + )} +
+
+ ); + })} +
+ + )} +
+
+
+
+
+ + {/* 기본값 (선택사항) */} +
+ + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], defaultValue: e.target.value }; + handleChange("parentDataMapping", updated); + }} + placeholder="값이 없을 때 사용할 기본값" + className="h-7 text-xs" + /> +
+ + {/* 삭제 버튼 */} + +
+
+ ))} +
+ + {(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

+
+
+
+ {/* 사용 예시 */}

💡 사용 예시

diff --git a/frontend/lib/registry/components/selected-items-detail-input/types.ts b/frontend/lib/registry/components/selected-items-detail-input/types.ts index 4fd0ba10..88d02c8e 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -121,6 +121,20 @@ export interface AutoCalculationConfig { calculationSteps?: CalculationStep[]; } +/** + * 🆕 부모 화면 데이터 매핑 설정 + */ +export interface ParentDataMapping { + /** 소스 테이블명 (필수) */ + sourceTable: string; + /** 소스 테이블의 필드명 */ + sourceField: string; + /** 저장할 테이블의 필드명 */ + targetField: string; + /** 부모 데이터가 없을 때 사용할 기본값 (선택사항) */ + defaultValue?: any; +} + /** * SelectedItemsDetailInput 컴포넌트 설정 타입 */ @@ -160,6 +174,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig { */ targetTable?: string; + /** + * 🆕 부모 화면 데이터 매핑 + * 이전 화면(예: 거래처 테이블)에서 넘어온 데이터를 저장 테이블의 필드에 자동 매핑 + * 예: { sourceField: "id", targetField: "customer_id" } + */ + parentDataMapping?: ParentDataMapping[]; + /** * 🆕 자동 계산 설정 * 특정 필드가 변경되면 다른 필드를 자동으로 계산 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 84cc3626..ca390a8f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -208,10 +208,17 @@ export class ButtonActionExecutor { console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId }); // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) - window.dispatchEvent(new CustomEvent("beforeFormSave")); + // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 + window.dispatchEvent(new CustomEvent("beforeFormSave", { + detail: { + formData: context.formData + } + })); // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 await new Promise(resolve => setTimeout(resolve, 100)); + + console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) console.log("🔍 [handleSave] formData 구조 확인:", { @@ -508,7 +515,14 @@ export class ButtonActionExecutor { context: ButtonActionContext, selectedItemsKeys: string[] ): Promise { - const { formData, tableName, screenId } = context; + const { formData, tableName, screenId, selectedRowsData, originalData } = context; + + console.log(`🔍 [handleBatchSave] context 확인:`, { + hasSelectedRowsData: !!selectedRowsData, + selectedRowsCount: selectedRowsData?.length || 0, + hasOriginalData: !!originalData, + originalDataKeys: originalData ? Object.keys(originalData) : [], + }); if (!tableName || !screenId) { toast.error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); @@ -520,6 +534,15 @@ export class ButtonActionExecutor { let failCount = 0; const errors: string[] = []; + // 🆕 부모 화면 데이터 준비 (parentDataMapping용) + // selectedRowsData 또는 originalData를 parentData로 사용 + const parentData = selectedRowsData?.[0] || originalData || {}; + + console.log(`🔍 [handleBatchSave] 부모 데이터:`, { + hasParentData: Object.keys(parentData).length > 0, + parentDataKeys: Object.keys(parentData), + }); + // 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리 for (const key of selectedItemsKeys) { // 🆕 새로운 데이터 구조: ItemData[] with fieldGroups @@ -531,22 +554,152 @@ export class ButtonActionExecutor { console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`); - // 각 품목의 모든 그룹의 모든 항목을 개별 저장 + // 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기 + // TODO: 실제로는 componentConfig에서 가져와야 함 + // 현재는 selectedItemsKeys[0]을 사용하여 임시로 가져옴 + const componentConfig = (context as any).componentConfigs?.[key]; + const parentDataMapping = componentConfig?.parentDataMapping || []; + + console.log(`🔍 [handleBatchSave] parentDataMapping 설정:`, { + hasMapping: parentDataMapping.length > 0, + mappings: parentDataMapping + }); + + // 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성 for (const item of items) { - const allGroupEntries = Object.values(item.fieldGroups).flat(); - console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${allGroupEntries.length}개 입력 항목)`); + const groupKeys = Object.keys(item.fieldGroups); + console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${groupKeys.length}개 그룹)`); - // 모든 그룹의 모든 항목을 개별 레코드로 저장 - for (const entry of allGroupEntries) { + // 각 그룹의 항목 배열 가져오기 + const groupArrays = groupKeys.map(groupKey => ({ + groupKey, + entries: item.fieldGroups[groupKey] || [] + })); + + console.log(`📊 [handleBatchSave] 그룹별 항목 수:`, + groupArrays.map(g => `${g.groupKey}: ${g.entries.length}개`).join(", ") + ); + + // 카티션 곱 계산 함수 + const cartesianProduct = (arrays: any[][]): any[][] => { + if (arrays.length === 0) return [[]]; + if (arrays.length === 1) return arrays[0].map(item => [item]); + + const [first, ...rest] = arrays; + const restProduct = cartesianProduct(rest); + + return first.flatMap(item => + restProduct.map(combination => [item, ...combination]) + ); + }; + + // 모든 그룹의 카티션 곱 생성 + const entryArrays = groupArrays.map(g => g.entries); + const combinations = cartesianProduct(entryArrays); + + console.log(`🔢 [handleBatchSave] 생성된 조합 수: ${combinations.length}개`); + + // 각 조합을 개별 레코드로 저장 + for (let i = 0; i < combinations.length; i++) { + const combination = combinations[i]; try { - // 원본 데이터 + 입력 데이터 병합 - const mergedData = { - ...item.originalData, - ...entry, - }; + // 🆕 부모 데이터 매핑 적용 + const mappedData: any = {}; - // id 필드 제거 (entry.id는 임시 ID이므로) - delete mergedData.id; + // 1. parentDataMapping 설정이 있으면 적용 + if (parentDataMapping.length > 0) { + console.log(` 🔗 [parentDataMapping] 매핑 시작 (${parentDataMapping.length}개 매핑)`); + + for (const mapping of parentDataMapping) { + // sourceTable을 기준으로 데이터 소스 결정 + let sourceData: any; + + // 🔍 sourceTable과 실제 데이터 테이블 비교 + // - parentData는 이전 화면 데이터 (예: 거래처 테이블) + // - item.originalData는 선택된 항목 데이터 (예: 품목 테이블) + + // 원본 데이터 테이블명 확인 (sourceTable이 config에 명시되어 있음) + const sourceTableName = mapping.sourceTable; + + // 현재 선택된 항목의 테이블 = config.sourceTable + const selectedItemTable = componentConfig?.sourceTable; + + if (sourceTableName === selectedItemTable) { + // 선택된 항목 데이터 사용 + sourceData = item.originalData; + console.log(` 📦 소스: 선택된 항목 데이터 (${sourceTableName})`); + } else { + // 이전 화면 데이터 사용 + sourceData = parentData; + console.log(` 👤 소스: 이전 화면 데이터 (${sourceTableName})`); + } + + const sourceValue = sourceData[mapping.sourceField]; + + if (sourceValue !== undefined && sourceValue !== null) { + mappedData[mapping.targetField] = sourceValue; + console.log(` ✅ [${sourceTableName}] ${mapping.sourceField} → ${mapping.targetField}: ${sourceValue}`); + } else if (mapping.defaultValue !== undefined) { + mappedData[mapping.targetField] = mapping.defaultValue; + console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 기본값 사용 → ${mapping.targetField}: ${mapping.defaultValue}`); + } else { + console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 건너뜀`); + } + } + } else { + // 🔧 parentDataMapping 설정이 없는 경우 기본 매핑 (하위 호환성) + console.log(` ⚠️ [parentDataMapping] 설정 없음, 기본 매핑 적용`); + + // 기본 item_id 매핑 (item.originalData의 id) + if (item.originalData.id) { + mappedData.item_id = item.originalData.id; + console.log(` ✅ [기본] item_id 매핑: ${item.originalData.id}`); + } + + // 기본 customer_id 매핑 (parentData의 id 또는 customer_id) + if (parentData.id || parentData.customer_id) { + mappedData.customer_id = parentData.customer_id || parentData.id; + console.log(` ✅ [기본] customer_id 매핑: ${mappedData.customer_id}`); + } + } + + // 공통 필드 복사 (company_code, currency_code 등) + if (item.originalData.company_code && !mappedData.company_code) { + mappedData.company_code = item.originalData.company_code; + } + if (item.originalData.currency_code && !mappedData.currency_code) { + mappedData.currency_code = item.originalData.currency_code; + } + + // 원본 데이터로 시작 (매핑된 데이터 사용) + let mergedData = { ...mappedData }; + + console.log(`🔍 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 병합 시작:`, { + originalDataKeys: Object.keys(item.originalData), + mappedDataKeys: Object.keys(mappedData), + combinationLength: combination.length + }); + + // 각 그룹의 항목 데이터를 순차적으로 병합 + for (let j = 0; j < combination.length; j++) { + const entry = combination[j]; + const { id, ...entryData } = entry; // id 제외 + + console.log(` 🔸 그룹 ${j + 1} 데이터 병합:`, entryData); + + mergedData = { ...mergedData, ...entryData }; + } + + console.log(`📝 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 최종 데이터:`, mergedData); + + // 🆕 조합 저장 시 id 필드 제거 (각 조합이 독립된 새 레코드가 되도록) + // originalData의 id는 원본 품목의 ID이므로, 새로운 customer_item_mapping 레코드 생성 시 제거 필요 + const { id: _removedId, ...dataWithoutId } = mergedData; + + console.log(`🔧 [handleBatchSave] 조합 ${i + 1}/${combinations.length} id 제거됨:`, { + removedId: _removedId, + hasId: 'id' in dataWithoutId + }); // 사용자 정보 추가 if (!context.userId) { @@ -557,16 +710,17 @@ export class ButtonActionExecutor { const companyCodeValue = context.companyCode || ""; const dataWithUserInfo = { - ...mergedData, - writer: mergedData.writer || writerValue, + ...dataWithoutId, // id가 제거된 데이터 사용 + writer: dataWithoutId.writer || writerValue, created_by: writerValue, updated_by: writerValue, - company_code: mergedData.company_code || companyCodeValue, + company_code: dataWithoutId.company_code || companyCodeValue, }; - console.log(`💾 [handleBatchSave] 입력 항목 저장:`, { + console.log(`💾 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 요청:`, { itemId: item.id, - entryId: entry.id, + combinationIndex: i + 1, + totalCombinations: combinations.length, data: dataWithUserInfo }); @@ -580,16 +734,19 @@ export class ButtonActionExecutor { if (saveResult.success) { successCount++; - console.log(`✅ [handleBatchSave] 입력 항목 저장 성공: ${item.id} > ${entry.id}`); + console.log(`✅ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 성공!`, { + savedId: saveResult.data?.id, + itemId: item.id + }); } else { failCount++; - errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${saveResult.message}`); - console.error(`❌ [handleBatchSave] 입력 항목 저장 실패: ${item.id} > ${entry.id}`, saveResult.message); + errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${saveResult.message}`); + console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 실패:`, saveResult.message); } } catch (error: any) { failCount++; - errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${error.message}`); - console.error(`❌ [handleBatchSave] 입력 항목 저장 오류: ${item.id} > ${entry.id}`, error); + errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${error.message}`); + console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 오류:`, error); } } } -- 2.43.0 From 97b5cd7a5b6d79ae5753abeadb6757024fa1aa71 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 19 Nov 2025 13:48:44 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=EB=8B=A4=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B6=80=EB=AA=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - 메인 화면(거래처 선택) → 첫 번째 모달(품목 선택) → 두 번째 모달(상세 입력) - selectedRowsData는 바로 이전 화면 데이터만 제공하여 2단계 이전 데이터 접근 불가 - customer_id가 NULL로 저장됨 해결: - modalDataStore의 전역 레지스트리에서 모든 누적 데이터 접근 - sourceTable에 따라 적절한 데이터 소스 자동 선택 - 거래처 데이터(customer_mng)를 modalDataStore에서 직접 가져옴 기술적 변경: - ButtonPrimaryComponent: allComponents에서 componentConfigs 수집 및 전달 - ButtonActionContext: componentConfigs 속성 추가 - handleBatchSave: modalDataStore에서 테이블별 데이터 조회 - parentDataMapping 로직: sourceTable 기반 데이터 소스 자동 감지 - 디버깅 로그 강화 (modalDataStore 키, 데이터 소스 추적) --- .../button-primary/ButtonPrimaryComponent.tsx | 14 +++++- frontend/lib/utils/buttonActions.ts | 49 +++++++++++++++---- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 1ae66e0b..112a285c 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -393,6 +393,16 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } + // 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등) + const componentConfigs: Record = {}; + if (allComponents && Array.isArray(allComponents)) { + for (const comp of allComponents) { + if (comp.id && comp.componentConfig) { + componentConfigs[comp.id] = comp.componentConfig; + } + } + } + const context: ButtonActionContext = { formData: formData || {}, originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 @@ -418,7 +428,9 @@ export const ButtonPrimaryComponent: React.FC = ({ // 플로우 선택된 데이터 정보 추가 flowSelectedData, flowSelectedStepId, - }; + // 🆕 컴포넌트별 설정 (parentDataMapping 등) + componentConfigs, + } as ButtonActionContext; // 확인이 필요한 액션인지 확인 if (confirmationRequiredActions.includes(processedConfig.action.type)) { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index ca390a8f..58a3b288 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -135,6 +135,9 @@ export interface ButtonActionContext { currentPage?: number; // 현재 페이지 pageSize?: number; // 페이지 크기 totalItems?: number; // 전체 항목 수 + + // 🆕 컴포넌트별 설정 (parentDataMapping 등) + componentConfigs?: Record; // 컴포넌트 ID → 컴포넌트 설정 } /** @@ -538,9 +541,19 @@ export class ButtonActionExecutor { // selectedRowsData 또는 originalData를 parentData로 사용 const parentData = selectedRowsData?.[0] || originalData || {}; + // 🆕 modalDataStore에서 누적된 모든 테이블 데이터 가져오기 + // (여러 단계 모달에서 전달된 데이터 접근용) + const modalDataStore = typeof window !== 'undefined' + ? (window as any).__modalDataRegistry || {} + : {}; + console.log(`🔍 [handleBatchSave] 부모 데이터:`, { hasParentData: Object.keys(parentData).length > 0, parentDataKeys: Object.keys(parentData), + parentDataFull: parentData, + selectedRowsData, + originalData, + modalDataStoreKeys: Object.keys(modalDataStore), }); // 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리 @@ -555,14 +568,15 @@ export class ButtonActionExecutor { console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`); // 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기 - // TODO: 실제로는 componentConfig에서 가져와야 함 - // 현재는 selectedItemsKeys[0]을 사용하여 임시로 가져옴 - const componentConfig = (context as any).componentConfigs?.[key]; + const componentConfig = context.componentConfigs?.[key]; const parentDataMapping = componentConfig?.parentDataMapping || []; console.log(`🔍 [handleBatchSave] parentDataMapping 설정:`, { + componentId: key, + hasComponentConfig: !!componentConfig, hasMapping: parentDataMapping.length > 0, - mappings: parentDataMapping + mappings: parentDataMapping, + sourceTable: componentConfig?.sourceTable, }); // 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성 @@ -615,8 +629,8 @@ export class ButtonActionExecutor { let sourceData: any; // 🔍 sourceTable과 실제 데이터 테이블 비교 - // - parentData는 이전 화면 데이터 (예: 거래처 테이블) - // - item.originalData는 선택된 항목 데이터 (예: 품목 테이블) + // - modalDataStore는 모든 이전 화면의 누적 데이터 (예: 거래처, 품목) + // - item.originalData는 현재 선택된 항목 데이터 (예: 품목 테이블) // 원본 데이터 테이블명 확인 (sourceTable이 config에 명시되어 있음) const sourceTableName = mapping.sourceTable; @@ -629,13 +643,30 @@ export class ButtonActionExecutor { sourceData = item.originalData; console.log(` 📦 소스: 선택된 항목 데이터 (${sourceTableName})`); } else { - // 이전 화면 데이터 사용 - sourceData = parentData; - console.log(` 👤 소스: 이전 화면 데이터 (${sourceTableName})`); + // 🆕 modalDataStore에서 해당 테이블 데이터 가져오기 + const tableData = modalDataStore[sourceTableName]; + if (tableData && Array.isArray(tableData) && tableData.length > 0) { + sourceData = tableData[0]; // 첫 번째 항목 사용 + console.log(` 🌐 소스: modalDataStore (${sourceTableName})`); + } else { + // 폴백: 이전 화면 데이터 사용 + sourceData = parentData; + console.log(` 👤 소스: 이전 화면 데이터 (${sourceTableName}) [폴백]`); + } } const sourceValue = sourceData[mapping.sourceField]; + console.log(` 🔍 데이터 소스 상세:`, { + sourceTable: sourceTableName, + selectedItemTable, + isFromSelectedItem: sourceTableName === selectedItemTable, + sourceDataKeys: Object.keys(sourceData), + sourceField: mapping.sourceField, + sourceValue, + targetField: mapping.targetField + }); + if (sourceValue !== undefined && sourceValue !== null) { mappedData[mapping.targetField] = sourceValue; console.log(` ✅ [${sourceTableName}] ${mapping.sourceField} → ${mapping.targetField}: ${sourceValue}`); -- 2.43.0 From 762ab8e684d57a56723dc19b521edf320207f6b8 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 19 Nov 2025 13:51:24 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20Zustand=20modalDataStore=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=80=EB=AA=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - modalDataStore가 window 전역 변수가 아닌 Zustand store임 - window.__modalDataRegistry로 접근 시도했으나 빈 객체 반환 - 거래처 데이터를 찾을 수 없어 customer_code 매핑 실패 해결: - useModalDataStore.getState().dataRegistry로 Zustand store 직접 접근 - ModalDataItem[] 배열에서 originalData 추출 - 각 테이블별 데이터를 modalDataStore 객체로 변환 - 거래처(customer_mng), 품목(item_info) 데이터 모두 접근 가능 기술적 변경: - dynamic import로 Zustand store 로드 - ModalDataItem 구조 이해 및 originalData 추출 - 에러 핸들링 (store 로드 실패 시) - 상세한 디버깅 로그 (테이블별 데이터 count) --- frontend/lib/utils/buttonActions.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 58a3b288..c56a9dd4 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -543,9 +543,25 @@ export class ButtonActionExecutor { // 🆕 modalDataStore에서 누적된 모든 테이블 데이터 가져오기 // (여러 단계 모달에서 전달된 데이터 접근용) - const modalDataStore = typeof window !== 'undefined' - ? (window as any).__modalDataRegistry || {} - : {}; + let modalDataStoreRegistry: Record = {}; + if (typeof window !== 'undefined') { + try { + // Zustand store에서 데이터 가져오기 + const { useModalDataStore } = await import('@/stores/modalDataStore'); + modalDataStoreRegistry = useModalDataStore.getState().dataRegistry; + } catch (error) { + console.warn("⚠️ modalDataStore 로드 실패:", error); + } + } + + // 각 테이블의 첫 번째 항목을 modalDataStore로 변환 + const modalDataStore: Record = {}; + Object.entries(modalDataStoreRegistry).forEach(([key, items]) => { + if (Array.isArray(items) && items.length > 0) { + // ModalDataItem[] → originalData 추출 + modalDataStore[key] = items.map(item => item.originalData || item); + } + }); console.log(`🔍 [handleBatchSave] 부모 데이터:`, { hasParentData: Object.keys(parentData).length > 0, @@ -554,6 +570,12 @@ export class ButtonActionExecutor { selectedRowsData, originalData, modalDataStoreKeys: Object.keys(modalDataStore), + modalDataStoreDetails: Object.fromEntries( + Object.entries(modalDataStore).map(([key, data]) => [ + key, + { count: Array.isArray(data) ? data.length : 1, hasData: !!data } + ]) + ), }); // 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리 -- 2.43.0 From d4895c363c502692384040dd70f9440fff78373a Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 19 Nov 2025 13:57:54 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=EB=94=94=EB=B2=84=EA=B9=85=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - handleBatchSave의 모든 console.log 제거 - 핵심 로직만 유지 (데이터 매핑, 조합 생성, 저장) - 코드 가독성 향상 제거된 로그: - modalDataStore 데이터 확인 로그 - parentDataMapping 설정 로그 - 품목/그룹 처리 로그 - 조합 생성/병합 로그 - 데이터 소스 상세 로그 - 저장 요청/결과 로그 유지된 기능: - Zustand modalDataStore에서 부모 데이터 가져오기 - 무한 깊이 모달 지원 - 완전히 설정 기반 parentDataMapping - 카티션 곱 조합 생성 - 하드코딩 없는 동적 매핑 --- frontend/lib/utils/buttonActions.ts | 102 +--------------------------- 1 file changed, 2 insertions(+), 100 deletions(-) diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index c56a9dd4..66375252 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -563,20 +563,6 @@ export class ButtonActionExecutor { } }); - console.log(`🔍 [handleBatchSave] 부모 데이터:`, { - hasParentData: Object.keys(parentData).length > 0, - parentDataKeys: Object.keys(parentData), - parentDataFull: parentData, - selectedRowsData, - originalData, - modalDataStoreKeys: Object.keys(modalDataStore), - modalDataStoreDetails: Object.fromEntries( - Object.entries(modalDataStore).map(([key, data]) => [ - key, - { count: Array.isArray(data) ? data.length : 1, hasData: !!data } - ]) - ), - }); // 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리 for (const key of selectedItemsKeys) { @@ -587,24 +573,13 @@ export class ButtonActionExecutor { fieldGroups: Record>; }>; - console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`); - // 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기 const componentConfig = context.componentConfigs?.[key]; const parentDataMapping = componentConfig?.parentDataMapping || []; - - console.log(`🔍 [handleBatchSave] parentDataMapping 설정:`, { - componentId: key, - hasComponentConfig: !!componentConfig, - hasMapping: parentDataMapping.length > 0, - mappings: parentDataMapping, - sourceTable: componentConfig?.sourceTable, - }); // 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성 for (const item of items) { const groupKeys = Object.keys(item.fieldGroups); - console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${groupKeys.length}개 그룹)`); // 각 그룹의 항목 배열 가져오기 const groupArrays = groupKeys.map(groupKey => ({ @@ -612,10 +587,6 @@ export class ButtonActionExecutor { entries: item.fieldGroups[groupKey] || [] })); - console.log(`📊 [handleBatchSave] 그룹별 항목 수:`, - groupArrays.map(g => `${g.groupKey}: ${g.entries.length}개`).join(", ") - ); - // 카티션 곱 계산 함수 const cartesianProduct = (arrays: any[][]): any[][] => { if (arrays.length === 0) return [[]]; @@ -633,8 +604,6 @@ export class ButtonActionExecutor { const entryArrays = groupArrays.map(g => g.entries); const combinations = cartesianProduct(entryArrays); - console.log(`🔢 [handleBatchSave] 생성된 조합 수: ${combinations.length}개`); - // 각 조합을 개별 레코드로 저장 for (let i = 0; i < combinations.length; i++) { const combination = combinations[i]; @@ -644,75 +613,38 @@ export class ButtonActionExecutor { // 1. parentDataMapping 설정이 있으면 적용 if (parentDataMapping.length > 0) { - console.log(` 🔗 [parentDataMapping] 매핑 시작 (${parentDataMapping.length}개 매핑)`); - for (const mapping of parentDataMapping) { - // sourceTable을 기준으로 데이터 소스 결정 let sourceData: any; - - // 🔍 sourceTable과 실제 데이터 테이블 비교 - // - modalDataStore는 모든 이전 화면의 누적 데이터 (예: 거래처, 품목) - // - item.originalData는 현재 선택된 항목 데이터 (예: 품목 테이블) - - // 원본 데이터 테이블명 확인 (sourceTable이 config에 명시되어 있음) const sourceTableName = mapping.sourceTable; - - // 현재 선택된 항목의 테이블 = config.sourceTable const selectedItemTable = componentConfig?.sourceTable; if (sourceTableName === selectedItemTable) { - // 선택된 항목 데이터 사용 sourceData = item.originalData; - console.log(` 📦 소스: 선택된 항목 데이터 (${sourceTableName})`); } else { - // 🆕 modalDataStore에서 해당 테이블 데이터 가져오기 const tableData = modalDataStore[sourceTableName]; if (tableData && Array.isArray(tableData) && tableData.length > 0) { - sourceData = tableData[0]; // 첫 번째 항목 사용 - console.log(` 🌐 소스: modalDataStore (${sourceTableName})`); + sourceData = tableData[0]; } else { - // 폴백: 이전 화면 데이터 사용 sourceData = parentData; - console.log(` 👤 소스: 이전 화면 데이터 (${sourceTableName}) [폴백]`); } } const sourceValue = sourceData[mapping.sourceField]; - console.log(` 🔍 데이터 소스 상세:`, { - sourceTable: sourceTableName, - selectedItemTable, - isFromSelectedItem: sourceTableName === selectedItemTable, - sourceDataKeys: Object.keys(sourceData), - sourceField: mapping.sourceField, - sourceValue, - targetField: mapping.targetField - }); - if (sourceValue !== undefined && sourceValue !== null) { mappedData[mapping.targetField] = sourceValue; - console.log(` ✅ [${sourceTableName}] ${mapping.sourceField} → ${mapping.targetField}: ${sourceValue}`); } else if (mapping.defaultValue !== undefined) { mappedData[mapping.targetField] = mapping.defaultValue; - console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 기본값 사용 → ${mapping.targetField}: ${mapping.defaultValue}`); - } else { - console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 건너뜀`); } } } else { // 🔧 parentDataMapping 설정이 없는 경우 기본 매핑 (하위 호환성) - console.log(` ⚠️ [parentDataMapping] 설정 없음, 기본 매핑 적용`); - - // 기본 item_id 매핑 (item.originalData의 id) if (item.originalData.id) { mappedData.item_id = item.originalData.id; - console.log(` ✅ [기본] item_id 매핑: ${item.originalData.id}`); } - // 기본 customer_id 매핑 (parentData의 id 또는 customer_id) if (parentData.id || parentData.customer_id) { mappedData.customer_id = parentData.customer_id || parentData.id; - console.log(` ✅ [기본] customer_id 매핑: ${mappedData.customer_id}`); } } @@ -727,32 +659,15 @@ export class ButtonActionExecutor { // 원본 데이터로 시작 (매핑된 데이터 사용) let mergedData = { ...mappedData }; - console.log(`🔍 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 병합 시작:`, { - originalDataKeys: Object.keys(item.originalData), - mappedDataKeys: Object.keys(mappedData), - combinationLength: combination.length - }); - // 각 그룹의 항목 데이터를 순차적으로 병합 for (let j = 0; j < combination.length; j++) { const entry = combination[j]; const { id, ...entryData } = entry; // id 제외 - - console.log(` 🔸 그룹 ${j + 1} 데이터 병합:`, entryData); - mergedData = { ...mergedData, ...entryData }; } - - console.log(`📝 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 최종 데이터:`, mergedData); // 🆕 조합 저장 시 id 필드 제거 (각 조합이 독립된 새 레코드가 되도록) - // originalData의 id는 원본 품목의 ID이므로, 새로운 customer_item_mapping 레코드 생성 시 제거 필요 const { id: _removedId, ...dataWithoutId } = mergedData; - - console.log(`🔧 [handleBatchSave] 조합 ${i + 1}/${combinations.length} id 제거됨:`, { - removedId: _removedId, - hasId: 'id' in dataWithoutId - }); // 사용자 정보 추가 if (!context.userId) { @@ -763,20 +678,13 @@ export class ButtonActionExecutor { const companyCodeValue = context.companyCode || ""; const dataWithUserInfo = { - ...dataWithoutId, // id가 제거된 데이터 사용 + ...dataWithoutId, writer: dataWithoutId.writer || writerValue, created_by: writerValue, updated_by: writerValue, company_code: dataWithoutId.company_code || companyCodeValue, }; - console.log(`💾 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 요청:`, { - itemId: item.id, - combinationIndex: i + 1, - totalCombinations: combinations.length, - data: dataWithUserInfo - }); - // INSERT 실행 const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); const saveResult = await DynamicFormApi.saveFormData({ @@ -787,19 +695,13 @@ export class ButtonActionExecutor { if (saveResult.success) { successCount++; - console.log(`✅ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 성공!`, { - savedId: saveResult.data?.id, - itemId: item.id - }); } else { failCount++; errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${saveResult.message}`); - console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 실패:`, saveResult.message); } } catch (error: any) { failCount++; errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${error.message}`); - console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 오류:`, error); } } } -- 2.43.0 From 34cd7ba9e320fce91356c5f54a8e6d7565a403bb Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 10:23:54 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EC=88=98=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20UPSERT=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SelectedItemsDetailInput 컴포넌트 수정 모드 지원 - 그룹화된 데이터 UPSERT API 추가 (/api/data/upsert-grouped) - 부모 키 기준으로 기존 레코드 조회 후 INSERT/UPDATE/DELETE - 각 레코드의 모든 필드 조합을 고유 키로 사용 - created_date 보존 (UPDATE 시) - 수정 모드에서 groupByColumns 기준으로 관련 레코드 조회 - 날짜 타입 ISO 형식 자동 감지 및 포맷팅 (YYYY.MM.DD) 주요 변경사항: - backend: dataService.upsertGroupedRecords() 메서드 구현 - backend: dataRoutes POST /api/data/upsert-grouped 엔드포인트 추가 - frontend: ScreenModal에서 groupByColumns 파라미터 전달 - frontend: SelectedItemsDetailInput 수정 모드 로직 추가 - frontend: 날짜 필드 타임존 제거 및 포맷팅 개선 --- backend-node/src/routes/dataRoutes.ts | 145 ++- backend-node/src/services/dataService.ts | 523 +++++++++- .../src/services/entityJoinService.ts | 112 +- backend-node/src/utils/dataFilterUtil.ts | 88 +- frontend/components/common/ScreenModal.tsx | 91 ++ .../config-panels/DataFilterConfigPanel.tsx | 297 ++++-- frontend/lib/api/data.ts | 115 ++- frontend/lib/api/entityJoin.ts | 25 +- .../SelectedItemsDetailInputComponent.tsx | 231 ++++- .../SplitPanelLayoutComponent.tsx | 388 +++++-- .../SplitPanelLayoutConfigPanel.tsx | 971 ++++++++++++++++-- .../components/split-panel-layout/types.ts | 37 + frontend/types/screen-management.ts | 26 +- 13 files changed, 2704 insertions(+), 345 deletions(-) diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 5193977a..b7ba4983 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -14,7 +14,7 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } = + const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } = req.query; // 입력값 검증 @@ -37,6 +37,9 @@ router.get( } } + // 🆕 enableEntityJoin 파싱 + const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; + // SQL 인젝션 방지를 위한 검증 const tables = [leftTable as string, rightTable as string]; const columns = [leftColumn as string, rightColumn as string]; @@ -64,6 +67,31 @@ router.get( // 회사 코드 추출 (멀티테넌시 필터링) const userCompany = req.user?.companyCode; + // displayColumns 파싱 (item_info.item_name 등) + let parsedDisplayColumns: Array<{ name: string; label?: string }> | undefined; + if (displayColumns) { + try { + parsedDisplayColumns = JSON.parse(displayColumns as string); + } catch (e) { + console.error("displayColumns 파싱 실패:", e); + } + } + + // 🆕 deduplication 파싱 + let parsedDeduplication: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } | undefined; + if (deduplication) { + try { + parsedDeduplication = JSON.parse(deduplication as string); + } catch (e) { + console.error("deduplication 파싱 실패:", e); + } + } + console.log(`🔗 조인 데이터 조회:`, { leftTable, rightTable, @@ -71,10 +99,13 @@ router.get( rightColumn, leftValue, userCompany, - dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그 + dataFilter: parsedDataFilter, + enableEntityJoin: enableEntityJoinFlag, + displayColumns: parsedDisplayColumns, // 🆕 표시 컬럼 로그 + deduplication: parsedDeduplication, // 🆕 중복 제거 로그 }); - // 조인 데이터 조회 (회사 코드 + 데이터 필터 전달) + // 조인 데이터 조회 (회사 코드 + 데이터 필터 + Entity 조인 + 표시 컬럼 + 중복 제거 전달) const result = await dataService.getJoinedData( leftTable as string, rightTable as string, @@ -82,7 +113,10 @@ router.get( rightColumn as string, leftValue as string, userCompany, - parsedDataFilter // 🆕 데이터 필터 전달 + parsedDataFilter, + enableEntityJoinFlag, + parsedDisplayColumns, // 🆕 표시 컬럼 전달 + parsedDeduplication // 🆕 중복 제거 설정 전달 ); if (!result.success) { @@ -305,10 +339,31 @@ router.get( }); } - console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`); + const { enableEntityJoin, groupByColumns } = req.query; + const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; + + // groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분) + let groupByColumnsArray: string[] = []; + if (groupByColumns) { + try { + if (typeof groupByColumns === "string") { + // JSON 형식이면 파싱, 아니면 쉼표로 분리 + groupByColumnsArray = groupByColumns.startsWith("[") + ? JSON.parse(groupByColumns) + : groupByColumns.split(",").map(c => c.trim()); + } + } catch (error) { + console.warn("groupByColumns 파싱 실패:", error); + } + } - // 레코드 상세 조회 - const result = await dataService.getRecordDetail(tableName, id); + console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { + enableEntityJoin: enableEntityJoinFlag, + groupByColumns: groupByColumnsArray + }); + + // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함) + const result = await dataService.getRecordDetail(tableName, id, enableEntityJoinFlag, groupByColumnsArray); if (!result.success) { return res.status(400).json(result); @@ -523,6 +578,82 @@ router.post( } ); +/** + * 그룹화된 데이터 UPSERT API + * POST /api/data/upsert-grouped + * + * 요청 본문: + * { + * tableName: string, + * parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }, + * records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ] + * } + */ +router.post( + "/upsert-grouped", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, parentKeys, records } = req.body; + + // 입력값 검증 + if (!tableName || !parentKeys || !records || !Array.isArray(records)) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).", + error: "MISSING_PARAMETERS", + }); + } + + // 테이블명 검증 + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`🔄 그룹화된 데이터 UPSERT: ${tableName}`, { + parentKeys, + recordCount: records.length, + }); + + // UPSERT 수행 + const result = await dataService.upsertGroupedRecords( + tableName, + parentKeys, + records + ); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, { + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + + return res.json({ + success: true, + message: "데이터가 저장되었습니다.", + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + } catch (error) { + console.error("그룹화된 데이터 UPSERT 오류:", error); + return res.status(500).json({ + success: false, + message: "데이터 저장 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + router.delete( "/:tableName/:id", authenticateToken, diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index bd7f74e1..dd40432e 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -14,6 +14,7 @@ * - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능 */ import { query, queryOne } from "../database/db"; +import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸 interface GetTableDataParams { @@ -53,6 +54,103 @@ const BLOCKED_TABLES = [ const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; class DataService { + /** + * 중복 데이터 제거 (메모리 내 처리) + */ + private deduplicateData( + data: any[], + config: { + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } + ): any[] { + if (!data || data.length === 0) return data; + + // 그룹별로 데이터 분류 + const groups: Record = {}; + + for (const row of data) { + const groupKey = row[config.groupByColumn]; + if (groupKey === undefined || groupKey === null) continue; + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push(row); + } + + // 각 그룹에서 하나의 행만 선택 + const result: any[] = []; + + for (const [groupKey, rows] of Object.entries(groups)) { + if (rows.length === 0) continue; + + let selectedRow: any; + + switch (config.keepStrategy) { + case "latest": + // 정렬 컬럼 기준 최신 (가장 큰 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal > bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "earliest": + // 정렬 컬럼 기준 최초 (가장 작은 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal < bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "base_price": + // base_price = true인 행 찾기 + selectedRow = rows.find(row => row.base_price === true) || rows[0]; + break; + + case "current_date": + // start_date <= CURRENT_DATE <= end_date 조건에 맞는 행 + const today = new Date(); + today.setHours(0, 0, 0, 0); // 시간 제거 + + selectedRow = rows.find(row => { + const startDate = row.start_date ? new Date(row.start_date) : null; + const endDate = row.end_date ? new Date(row.end_date) : null; + + if (startDate) startDate.setHours(0, 0, 0, 0); + if (endDate) endDate.setHours(0, 0, 0, 0); + + const afterStart = !startDate || today >= startDate; + const beforeEnd = !endDate || today <= endDate; + + return afterStart && beforeEnd; + }) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행 + break; + + default: + selectedRow = rows[0]; + } + + result.push(selectedRow); + } + + return result; + } + /** * 테이블 접근 검증 (공통 메서드) */ @@ -374,11 +472,13 @@ class DataService { } /** - * 레코드 상세 조회 + * 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회) */ async getRecordDetail( tableName: string, - id: string | number + id: string | number, + enableEntityJoin: boolean = false, + groupByColumns: string[] = [] ): Promise> { try { // 테이블 접근 검증 @@ -401,6 +501,87 @@ class DataService { pkColumn = pkResult[0].attname; } + // 🆕 Entity Join이 활성화된 경우 + if (enableEntityJoin) { + const { EntityJoinService } = await import("./entityJoinService"); + const entityJoinService = new EntityJoinService(); + + // Entity Join 구성 감지 + const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + + if (joinConfigs.length > 0) { + console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`); + + // Entity Join 쿼리 생성 (개별 파라미터로 전달) + const { query: joinQuery } = entityJoinService.buildJoinQuery( + tableName, + joinConfigs, + ["*"], + `main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결 + ); + + const result = await pool.query(joinQuery, [id]); + + if (result.rows.length === 0) { + return { + success: false, + message: "레코드를 찾을 수 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + + console.log(`✅ Entity Join 데이터 조회 성공:`, result.rows[0]); + + // 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회 + if (groupByColumns.length > 0) { + const baseRecord = result.rows[0]; + + // 그룹핑 컬럼들의 값 추출 + const groupConditions: string[] = []; + const groupValues: any[] = []; + let paramIndex = 1; + + for (const col of groupByColumns) { + const value = baseRecord[col]; + if (value !== undefined && value !== null) { + groupConditions.push(`main."${col}" = $${paramIndex}`); + groupValues.push(value); + paramIndex++; + } + } + + if (groupConditions.length > 0) { + const groupWhereClause = groupConditions.join(" AND "); + + console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues); + + // 그룹핑 기준으로 모든 레코드 조회 + const { query: groupQuery } = entityJoinService.buildJoinQuery( + tableName, + joinConfigs, + ["*"], + groupWhereClause + ); + + const groupResult = await pool.query(groupQuery, groupValues); + + console.log(`✅ 그룹 레코드 조회 성공: ${groupResult.rows.length}개`); + + return { + success: true, + data: groupResult.rows, // 🔧 배열로 반환! + }; + } + } + + return { + success: true, + data: result.rows[0], // 그룹핑 없으면 단일 레코드 + }; + } + } + + // 기본 쿼리 (Entity Join 없음) const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`; const result = await query(queryText, [id]); @@ -427,7 +608,7 @@ class DataService { } /** - * 조인된 데이터 조회 + * 조인된 데이터 조회 (🆕 Entity 조인 지원) */ async getJoinedData( leftTable: string, @@ -436,7 +617,15 @@ class DataService { rightColumn: string, leftValue?: string | number, userCompany?: string, - dataFilter?: any // 🆕 데이터 필터 + dataFilter?: any, // 🆕 데이터 필터 + enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화 + displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등) + deduplication?: { // 🆕 중복 제거 설정 + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } ): Promise> { try { // 왼쪽 테이블 접근 검증 @@ -451,6 +640,143 @@ class DataService { return rightValidation.error!; } + // 🆕 Entity 조인이 활성화된 경우 entityJoinService 사용 + if (enableEntityJoin) { + try { + const { entityJoinService } = await import("./entityJoinService"); + const joinConfigs = await entityJoinService.detectEntityJoins(rightTable); + + // 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등) + if (displayColumns && Array.isArray(displayColumns)) { + // 테이블별로 요청된 컬럼들을 그룹핑 + const tableColumns: Record> = {}; + + for (const col of displayColumns) { + if (col.name && col.name.includes('.')) { + const [refTable, refColumn] = col.name.split('.'); + if (!tableColumns[refTable]) { + tableColumns[refTable] = new Set(); + } + tableColumns[refTable].add(refColumn); + } + } + + // 각 테이블별로 처리 + for (const [refTable, refColumns] of Object.entries(tableColumns)) { + // 이미 조인 설정에 있는지 확인 + const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable); + + if (existingJoins.length > 0) { + // 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리 + for (const refColumn of refColumns) { + // 이미 해당 컬럼을 표시하는 조인이 있는지 확인 + const existingJoin = existingJoins.find( + jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn + ); + + if (!existingJoin) { + // 없으면 새 조인 설정 복제하여 추가 + const baseJoin = existingJoins[0]; + const newJoin = { + ...baseJoin, + displayColumns: [refColumn], + aliasColumn: `${baseJoin.sourceColumn}_${refColumn}`, // 고유한 별칭 생성 (예: item_id_size) + // ⚠️ 중요: referenceTable과 referenceColumn을 명시하여 JOIN된 테이블에서 가져옴 + referenceTable: refTable, + referenceColumn: baseJoin.referenceColumn, // item_number 등 + }; + joinConfigs.push(newJoin); + console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`); + } + } + } else { + console.warn(`⚠️ 조인 설정 없음: ${refTable}`); + } + } + } + + if (joinConfigs.length > 0) { + console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`); + + // WHERE 조건 생성 + const whereConditions: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + // 좌측 테이블 조인 조건 (leftValue로 필터링) + // rightColumn을 직접 사용 (customer_item_mapping.customer_id = 'CUST-0002') + if (leftValue !== undefined && leftValue !== null) { + whereConditions.push(`main."${rightColumn}" = $${paramIndex}`); + values.push(leftValue); + paramIndex++; + } + + // 회사별 필터링 + if (userCompany && userCompany !== "*") { + const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code"); + if (hasCompanyCode) { + whereConditions.push(`main.company_code = $${paramIndex}`); + values.push(userCompany); + paramIndex++; + } + } + + // 데이터 필터 적용 (buildDataFilterWhereClause 사용) + if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) { + const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil"); + const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex); + if (filterResult.whereClause) { + whereConditions.push(filterResult.whereClause); + values.push(...filterResult.params); + paramIndex += filterResult.params.length; + console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause); + console.log(`📊 필터 파라미터:`, filterResult.params); + } + } + + const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : ""; + + // Entity 조인 쿼리 빌드 + // buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달 + const selectColumns = ["*"]; + + const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery( + rightTable, + joinConfigs, + selectColumns, + whereClause, + "", + undefined, + undefined + ); + + console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery); + console.log(`🔍 파라미터:`, values); + + const result = await pool.query(finalQuery, values); + + console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${result.rows.length}개`); + + // 🆕 중복 제거 처리 + let finalData = result.rows; + if (deduplication?.enabled && deduplication.groupByColumn) { + console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); + finalData = this.deduplicateData(result.rows, deduplication); + console.log(`✅ 중복 제거 완료: ${result.rows.length}개 → ${finalData.length}개`); + } + + return { + success: true, + data: finalData, + }; + } + } catch (error) { + console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error); + // Entity 조인 실패 시 기본 조인으로 폴백 + } + } + + // 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시) let queryText = ` SELECT DISTINCT r.* FROM "${rightTable}" r @@ -501,9 +827,17 @@ class DataService { const result = await query(queryText, values); + // 🆕 중복 제거 처리 + let finalData = result; + if (deduplication?.enabled && deduplication.groupByColumn) { + console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); + finalData = this.deduplicateData(result, deduplication); + console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`); + } + return { success: true, - data: result, + data: finalData, }; } catch (error) { console.error( @@ -728,6 +1062,185 @@ class DataService { }; } } + + /** + * 그룹화된 데이터 UPSERT + * - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아 + * - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행 + * - 각 레코드의 모든 필드 조합을 고유 키로 사용 + */ + async upsertGroupedRecords( + tableName: string, + parentKeys: Record, + records: Array> + ): Promise> { + try { + // 테이블 접근 권한 검증 + if (!this.canAccessTable(tableName)) { + return { + success: false, + message: `테이블 '${tableName}'에 접근할 수 없습니다.`, + error: "ACCESS_DENIED", + }; + } + + // Primary Key 감지 + const pkColumn = await this.detectPrimaryKey(tableName); + if (!pkColumn) { + return { + success: false, + message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`, + error: "PRIMARY_KEY_NOT_FOUND", + }; + } + + console.log(`🔍 UPSERT 시작: ${tableName}`, { + parentKeys, + newRecordsCount: records.length, + primaryKey: pkColumn, + }); + + // 1. 기존 DB 레코드 조회 (parentKeys 기준) + const whereConditions: string[] = []; + const whereValues: any[] = []; + let paramIndex = 1; + + for (const [key, value] of Object.entries(parentKeys)) { + whereConditions.push(`"${key}" = $${paramIndex}`); + whereValues.push(value); + paramIndex++; + } + + const whereClause = whereConditions.join(" AND "); + const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`; + + console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues }); + + const existingRecords = await pool.query(selectQuery, whereValues); + + console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`); + + // 2. 새 레코드와 기존 레코드 비교 + let inserted = 0; + let updated = 0; + let deleted = 0; + + // 새 레코드 처리 (INSERT or UPDATE) + for (const newRecord of records) { + // 전체 레코드 데이터 (parentKeys + newRecord) + const fullRecord = { ...parentKeys, ...newRecord }; + + // 고유 키: parentKeys 제외한 나머지 필드들 + const uniqueFields = Object.keys(newRecord); + + // 기존 레코드에서 일치하는 것 찾기 + const existingRecord = existingRecords.rows.find((existing) => { + return uniqueFields.every((field) => { + const existingValue = existing[field]; + const newValue = newRecord[field]; + + // null/undefined 처리 + if (existingValue == null && newValue == null) return true; + if (existingValue == null || newValue == null) return false; + + // Date 타입 처리 + if (existingValue instanceof Date && typeof newValue === 'string') { + return existingValue.toISOString().split('T')[0] === newValue.split('T')[0]; + } + + // 문자열 비교 + return String(existingValue) === String(newValue); + }); + }); + + if (existingRecord) { + // UPDATE: 기존 레코드가 있으면 업데이트 + const updateFields: string[] = []; + const updateValues: any[] = []; + let updateParamIndex = 1; + + for (const [key, value] of Object.entries(fullRecord)) { + if (key !== pkColumn) { // Primary Key는 업데이트하지 않음 + updateFields.push(`"${key}" = $${updateParamIndex}`); + updateValues.push(value); + updateParamIndex++; + } + } + + updateValues.push(existingRecord[pkColumn]); // WHERE 조건용 + const updateQuery = ` + UPDATE "${tableName}" + SET ${updateFields.join(", ")}, updated_date = NOW() + WHERE "${pkColumn}" = $${updateParamIndex} + `; + + await pool.query(updateQuery, updateValues); + updated++; + + console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); + } else { + // INSERT: 기존 레코드가 없으면 삽입 + const insertFields = Object.keys(fullRecord); + const insertPlaceholders = insertFields.map((_, idx) => `$${idx + 1}`); + const insertValues = Object.values(fullRecord); + + const insertQuery = ` + INSERT INTO "${tableName}" (${insertFields.map(f => `"${f}"`).join(", ")}) + VALUES (${insertPlaceholders.join(", ")}) + `; + + await pool.query(insertQuery, insertValues); + inserted++; + + console.log(`➕ INSERT: 새 레코드`); + } + } + + // 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것) + for (const existingRecord of existingRecords.rows) { + const uniqueFields = Object.keys(records[0] || {}); + + const stillExists = records.some((newRecord) => { + return uniqueFields.every((field) => { + const existingValue = existingRecord[field]; + const newValue = newRecord[field]; + + if (existingValue == null && newValue == null) return true; + if (existingValue == null || newValue == null) return false; + + if (existingValue instanceof Date && typeof newValue === 'string') { + return existingValue.toISOString().split('T')[0] === newValue.split('T')[0]; + } + + return String(existingValue) === String(newValue); + }); + }); + + if (!stillExists) { + // DELETE: 새 레코드에 없으면 삭제 + const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; + await pool.query(deleteQuery, [existingRecord[pkColumn]]); + deleted++; + + console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); + } + } + + console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted }); + + return { + success: true, + data: { inserted, updated, deleted }, + }; + } catch (error) { + console.error(`UPSERT 오류 (${tableName}):`, error); + return { + success: false, + message: "데이터 저장 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } } export const dataService = new DataService(); diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index fef50914..88aca52d 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -81,18 +81,18 @@ export class EntityJoinService { let referenceColumn = column.reference_column; let displayColumn = column.display_column; - if (column.input_type === 'category') { - // 카테고리 타입: reference 정보가 비어있어도 자동 설정 - referenceTable = referenceTable || 'table_column_category_values'; - referenceColumn = referenceColumn || 'value_code'; - displayColumn = displayColumn || 'value_label'; - - logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, { - referenceTable, - referenceColumn, - displayColumn, - }); - } + if (column.input_type === "category") { + // 카테고리 타입: reference 정보가 비어있어도 자동 설정 + referenceTable = referenceTable || "table_column_category_values"; + referenceColumn = referenceColumn || "value_code"; + displayColumn = displayColumn || "value_label"; + + logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, { + referenceTable, + referenceColumn, + displayColumn, + }); + } logger.info(`🔍 Entity 컬럼 상세 정보:`, { column_name: column.column_name, @@ -214,8 +214,14 @@ export class EntityJoinService { ): { query: string; aliasMap: Map } { try { // 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지) + // "*"는 특별 처리: AS 없이 그냥 main.*만 const baseColumns = selectColumns - .map((col) => `main.${col}::TEXT AS ${col}`) + .map((col) => { + if (col === "*") { + return "main.*"; + } + return `main.${col}::TEXT AS ${col}`; + }) .join(", "); // Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리) @@ -255,7 +261,9 @@ export class EntityJoinService { // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응) const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; aliasMap.set(aliasKey, alias); - logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`); + logger.info( + `🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}` + ); }); const joinColumns = joinConfigs @@ -266,64 +274,48 @@ export class EntityJoinService { config.displayColumn, ]; const separator = config.separator || " - "; - + // 결과 컬럼 배열 (aliasColumn + _label 필드) const resultColumns: string[] = []; if (displayColumns.length === 0 || !displayColumns[0]) { // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 // 조인 테이블의 referenceColumn을 기본값으로 사용 - resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`); + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` + ); } else if (displayColumns.length === 1) { // 단일 컬럼인 경우 const col = displayColumns[0]; - const isJoinTableColumn = [ - "dept_name", - "dept_code", - "master_user_id", - "location_name", - "parent_dept_code", - "master_sabun", - "location", - "data_type", - "company_name", - "sales_yn", - "status", - "value_label", // table_column_category_values - "user_name", // user_info - ].includes(col); + + // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 + // 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원 + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; if (isJoinTableColumn) { - resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`); - + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` + ); + // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) // sourceColumn_label 형식으로 추가 - resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`); + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label` + ); } else { - resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`); + resultColumns.push( + `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` + ); } } else { // 여러 컬럼인 경우 CONCAT으로 연결 // 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리 const concatParts = displayColumns .map((col) => { - // 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용) - // 현재는 dept_info 테이블의 컬럼들을 확인 - const isJoinTableColumn = [ - "dept_name", - "dept_code", - "master_user_id", - "location_name", - "parent_dept_code", - "master_sabun", - "location", - "data_type", - "company_name", - "sales_yn", - "status", - "value_label", // table_column_category_values - "user_name", // user_info - ].includes(col); + // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; if (isJoinTableColumn) { // 조인 테이블 컬럼은 조인 별칭 사용 @@ -337,7 +329,7 @@ export class EntityJoinService { resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`); } - + // 모든 resultColumns를 반환 return resultColumns.join(", "); }) @@ -356,13 +348,13 @@ export class EntityJoinService { .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - + // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); @@ -424,7 +416,7 @@ export class EntityJoinService { } // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { logger.info( `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` ); @@ -578,13 +570,13 @@ export class EntityJoinService { .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - + // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); diff --git a/backend-node/src/utils/dataFilterUtil.ts b/backend-node/src/utils/dataFilterUtil.ts index d00861fb..a4e81fd6 100644 --- a/backend-node/src/utils/dataFilterUtil.ts +++ b/backend-node/src/utils/dataFilterUtil.ts @@ -6,9 +6,28 @@ export interface ColumnFilter { id: string; columnName: string; - operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null"; + operator: + | "equals" + | "not_equals" + | "in" + | "not_in" + | "contains" + | "starts_with" + | "ends_with" + | "is_null" + | "is_not_null" + | "greater_than" + | "less_than" + | "greater_than_or_equal" + | "less_than_or_equal" + | "between" + | "date_range_contains"; value: string | string[]; - valueType: "static" | "category" | "code"; + valueType: "static" | "category" | "code" | "dynamic"; + rangeConfig?: { + startColumn: string; + endColumn: string; + }; } export interface DataFilterConfig { @@ -123,6 +142,71 @@ export function buildDataFilterWhereClause( conditions.push(`${columnRef} IS NOT NULL`); break; + case "greater_than": + conditions.push(`${columnRef} > $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "less_than": + conditions.push(`${columnRef} < $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "greater_than_or_equal": + conditions.push(`${columnRef} >= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "less_than_or_equal": + conditions.push(`${columnRef} <= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "between": + if (Array.isArray(value) && value.length === 2) { + conditions.push(`${columnRef} BETWEEN $${paramIndex} AND $${paramIndex + 1}`); + params.push(value[0], value[1]); + paramIndex += 2; + } + break; + + case "date_range_contains": + // 날짜 범위 포함: start_date <= value <= end_date + // filter.rangeConfig = { startColumn: "start_date", endColumn: "end_date" } + // NULL 처리: + // - start_date만 있고 end_date가 NULL이면: start_date <= value (이후 계속) + // - end_date만 있고 start_date가 NULL이면: value <= end_date (이전 계속) + // - 둘 다 있으면: start_date <= value <= end_date + if (filter.rangeConfig && filter.rangeConfig.startColumn && filter.rangeConfig.endColumn) { + const startCol = getColumnRef(filter.rangeConfig.startColumn); + const endCol = getColumnRef(filter.rangeConfig.endColumn); + + // value가 "TODAY"면 현재 날짜로 변환 + const actualValue = filter.valueType === "dynamic" && value === "TODAY" + ? "CURRENT_DATE" + : `$${paramIndex}`; + + if (actualValue === "CURRENT_DATE") { + // CURRENT_DATE는 파라미터가 아니므로 직접 SQL에 포함 + // NULL 처리: (start_date IS NULL OR start_date <= CURRENT_DATE) AND (end_date IS NULL OR end_date >= CURRENT_DATE) + conditions.push( + `((${startCol} IS NULL OR ${startCol} <= CURRENT_DATE) AND (${endCol} IS NULL OR ${endCol} >= CURRENT_DATE))` + ); + } else { + // NULL 처리: (start_date IS NULL OR start_date <= $param) AND (end_date IS NULL OR end_date >= $param) + conditions.push( + `((${startCol} IS NULL OR ${startCol} <= $${paramIndex}) AND (${endCol} IS NULL OR ${endCol} >= $${paramIndex}))` + ); + params.push(value); + paramIndex++; + } + } + break; + default: // 알 수 없는 연산자는 무시 break; diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 087444b7..72a1a2ca 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -221,6 +221,97 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("API 응답:", { screenInfo, layoutData }); + // 🆕 URL 파라미터 확인 (수정 모드) + if (typeof window !== "undefined") { + const urlParams = new URLSearchParams(window.location.search); + const mode = urlParams.get("mode"); + const editId = urlParams.get("editId"); + const tableName = urlParams.get("tableName") || screenInfo.tableName; + const groupByColumnsParam = urlParams.get("groupByColumns"); + + console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam }); + + // 수정 모드이고 editId가 있으면 해당 레코드 조회 + if (mode === "edit" && editId && tableName) { + try { + console.log("🔍 수정 데이터 조회 시작:", { tableName, editId, groupByColumnsParam }); + + const { dataApi } = await import("@/lib/api/data"); + + // groupByColumns 파싱 + let groupByColumns: string[] = []; + if (groupByColumnsParam) { + try { + groupByColumns = JSON.parse(groupByColumnsParam); + console.log("✅ [ScreenModal] groupByColumns 파싱 성공:", groupByColumns); + } catch (e) { + console.warn("groupByColumns 파싱 실패:", e); + } + } else { + console.warn("⚠️ [ScreenModal] groupByColumnsParam이 없습니다!"); + } + + console.log("🚀 [ScreenModal] API 호출 직전:", { + tableName, + editId, + enableEntityJoin: true, + groupByColumns, + groupByColumnsLength: groupByColumns.length, + }); + + // 🆕 apiClient를 named import로 가져오기 + const { apiClient } = await import("@/lib/api/client"); + const params: any = { + enableEntityJoin: true, + }; + if (groupByColumns.length > 0) { + params.groupByColumns = JSON.stringify(groupByColumns); + console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns); + } + + console.log("📡 [ScreenModal] 실제 API 요청:", { + url: `/data/${tableName}/${editId}`, + params, + }); + + const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params }); + const response = apiResponse.data; + + console.log("📩 [ScreenModal] API 응답 받음:", { + success: response.success, + hasData: !!response.data, + dataType: response.data ? (Array.isArray(response.data) ? "배열" : "객체") : "없음", + dataLength: Array.isArray(response.data) ? response.data.length : 1, + }); + + if (response.success && response.data) { + // 배열인 경우 (그룹핑) vs 단일 객체 + const isArray = Array.isArray(response.data); + + if (isArray) { + console.log(`✅ 수정 데이터 로드 완료 (그룹 레코드: ${response.data.length}개)`); + console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2)); + } else { + console.log("✅ 수정 데이터 로드 완료 (필드 수:", Object.keys(response.data).length, ")"); + console.log("📊 모든 필드 키:", Object.keys(response.data)); + console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2)); + } + + setFormData(response.data); + + // setFormData 직후 확인 + console.log("🔄 setFormData 호출 완료"); + } else { + console.error("❌ 수정 데이터 로드 실패:", response.error); + toast.error("데이터를 불러올 수 없습니다."); + } + } catch (error) { + console.error("❌ 수정 데이터 조회 오류:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } + } + } + // screenApi는 직접 데이터를 반환하므로 .success 체크 불필요 if (screenInfo && layoutData) { const components = layoutData.components || []; diff --git a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx index f3fed8bf..724c2453 100644 --- a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx @@ -186,75 +186,93 @@ export function DataFilterConfigPanel({
- {/* 컬럼 선택 */} -
- - -
+ {/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */} + {filter.operator !== "date_range_contains" && ( +
+ + +
+ )} {/* 연산자 선택 */}
- {/* 값 타입 선택 (카테고리/코드 컬럼만) */} - {isCategoryOrCodeColumn(filter.columnName) && ( + {/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */} + {filter.operator === "date_range_contains" && ( + <> +
+

+ 💡 날짜 범위 필터링 규칙: +
• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터 +
• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터 +
• 둘 다 있으면 → 기간 내 데이터만 +

+
+
+ + +
+
+ + +
+ + )} + + {/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */} + {(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
)} - {/* 값 입력 (NULL 체크 제외) */} - {filter.operator !== "is_null" && filter.operator !== "is_not_null" && ( + {/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */} + {filter.operator !== "is_null" && + filter.operator !== "is_not_null" && + !(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */} @@ -328,11 +455,22 @@ export function DataFilterConfigPanel({ placeholder="쉼표로 구분 (예: 값1, 값2, 값3)" className="h-8 text-xs sm:h-10 sm:text-sm" /> + ) : filter.operator === "between" ? ( + { + const values = e.target.value.split("~").map((v) => v.trim()); + handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]); + }} + placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> ) : ( handleFilterChange(filter.id, "value", e.target.value)} - placeholder="필터 값 입력" + placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"} className="h-8 text-xs sm:h-10 sm:text-sm" /> )} @@ -341,10 +479,23 @@ export function DataFilterConfigPanel({ ? "카테고리 값을 선택하세요" : filter.operator === "in" || filter.operator === "not_in" ? "여러 값은 쉼표(,)로 구분하세요" + : filter.operator === "between" + ? "시작과 종료 값을 ~로 구분하세요" + : filter.operator === "date_range_contains" + ? "기간 내에 포함되는지 확인할 날짜를 선택하세요" : "필터링할 값을 입력하세요"}

)} + + {/* date_range_contains의 dynamic 타입 안내 */} + {filter.operator === "date_range_contains" && filter.valueType === "dynamic" && ( +
+

+ ℹ️ 오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다. +

+
+ )}
))}
diff --git a/frontend/lib/api/data.ts b/frontend/lib/api/data.ts index b3c023bf..72002ad1 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -42,10 +42,49 @@ export const dataApi = { * 특정 레코드 상세 조회 * @param tableName 테이블명 * @param id 레코드 ID + * @param enableEntityJoin Entity 조인 활성화 여부 (기본값: false) + * @param groupByColumns 그룹핑 기준 컬럼들 (배열) */ - getRecordDetail: async (tableName: string, id: string | number): Promise => { - const response = await apiClient.get(`/data/${tableName}/${id}`); - return response.data?.data || response.data; + getRecordDetail: async ( + tableName: string, + id: string | number, + enableEntityJoin: boolean = false, + groupByColumns: string[] = [] + ): Promise<{ success: boolean; data?: any; error?: string }> => { + try { + const params: any = {}; + if (enableEntityJoin) { + params.enableEntityJoin = true; + } + if (groupByColumns.length > 0) { + params.groupByColumns = JSON.stringify(groupByColumns); + } + + console.log("🌐 [dataApi.getRecordDetail] API 호출:", { + tableName, + id, + enableEntityJoin, + groupByColumns, + params, + url: `/data/${tableName}/${id}`, + }); + + const response = await apiClient.get(`/data/${tableName}/${id}`, { params }); + + console.log("📥 [dataApi.getRecordDetail] API 응답:", { + success: response.data?.success, + dataType: Array.isArray(response.data?.data) ? "배열" : "객체", + dataCount: Array.isArray(response.data?.data) ? response.data.data.length : 1, + }); + + return response.data; // { success: true, data: ... } 형식 그대로 반환 + } catch (error: any) { + console.error("❌ [dataApi.getRecordDetail] API 오류:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "레코드 조회 실패", + }; + } }, /** @@ -55,6 +94,9 @@ export const dataApi = { * @param leftColumn 좌측 컬럼명 * @param rightColumn 우측 컬럼명 (외래키) * @param leftValue 좌측 값 (필터링) + * @param dataFilter 데이터 필터 + * @param enableEntityJoin Entity 조인 활성화 + * @param displayColumns 표시할 컬럼 목록 (tableName.columnName 형식 포함) */ getJoinedData: async ( leftTable: string, @@ -62,7 +104,15 @@ export const dataApi = { leftColumn: string, rightColumn: string, leftValue?: any, - dataFilter?: any, // 🆕 데이터 필터 + dataFilter?: any, + enableEntityJoin?: boolean, + displayColumns?: Array<{ name: string; label?: string }>, + deduplication?: { // 🆕 중복 제거 설정 + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + }, ): Promise => { const response = await apiClient.get(`/data/join`, { params: { @@ -71,7 +121,10 @@ export const dataApi = { leftColumn, rightColumn, leftValue, - dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달 + dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, + enableEntityJoin: enableEntityJoin ?? true, + displayColumns: displayColumns ? JSON.stringify(displayColumns) : undefined, // 🆕 표시 컬럼 전달 + deduplication: deduplication ? JSON.stringify(deduplication) : undefined, // 🆕 중복 제거 설정 전달 }, }); const raw = response.data || {}; @@ -115,4 +168,56 @@ export const dataApi = { const response = await apiClient.delete(`/data/${tableName}/${id}`); return response.data; // success, message 포함된 전체 응답 반환 }, + + /** + * 특정 레코드 상세 조회 + * @param tableName 테이블명 + * @param id 레코드 ID + * @param enableEntityJoin Entity 조인 활성화 여부 (기본값: false) + */ + getRecordDetail: async ( + tableName: string, + id: string | number, + enableEntityJoin: boolean = false + ): Promise<{ success: boolean; data?: any; error?: string }> => { + try { + const params: any = {}; + if (enableEntityJoin) { + params.enableEntityJoin = "true"; + } + const response = await apiClient.get(`/data/${tableName}/${id}`, { params }); + return response.data; // { success: true, data: ... } 형식 그대로 반환 + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message || "레코드 조회 실패", + }; + } + }, + + /** + * 그룹화된 데이터 UPSERT + * @param tableName 테이블명 + * @param parentKeys 부모 키 (예: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }) + * @param records 레코드 배열 + */ + upsertGroupedRecords: async ( + tableName: string, + parentKeys: Record, + records: Array> + ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => { + try { + const response = await apiClient.post('/data/upsert-grouped', { + tableName, + parentKeys, + records, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message || "데이터 저장 실패", + }; + } + }, }; diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index 4402b557..a84f3355 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -71,25 +71,6 @@ export const entityJoinApi = { dataFilter?: any; // 🆕 데이터 필터 } = {}, ): Promise => { - const searchParams = new URLSearchParams(); - - if (params.page) searchParams.append("page", params.page.toString()); - if (params.size) searchParams.append("size", params.size.toString()); - if (params.sortBy) searchParams.append("sortBy", params.sortBy); - if (params.sortOrder) searchParams.append("sortOrder", params.sortOrder); - if (params.enableEntityJoin !== undefined) { - searchParams.append("enableEntityJoin", params.enableEntityJoin.toString()); - } - - // 검색 조건 추가 - if (params.search) { - Object.entries(params.search).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - searchParams.append(key, String(value)); - } - }); - } - // 🔒 멀티테넌시: company_code 자동 필터링 활성화 const autoFilter = { enabled: true, @@ -99,7 +80,11 @@ export const entityJoinApi = { const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params: { - ...params, + page: params.page, + size: params.size, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + enableEntityJoin: params.enableEntityJoin, search: params.search ? JSON.stringify(params.search) : undefined, additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined, screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index c615012e..82abcf20 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -216,6 +216,98 @@ export const SelectedItemsDetailInputComponent: React.FC { + // 🆕 수정 모드: formData에서 데이터 로드 (URL에 mode=edit이 있으면) + const urlParams = new URLSearchParams(window.location.search); + const mode = urlParams.get("mode"); + + if (mode === "edit" && formData) { + // 배열인지 단일 객체인지 확인 + const isArray = Array.isArray(formData); + const dataArray = isArray ? formData : [formData]; + + if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { + console.warn("⚠️ [SelectedItemsDetailInput] formData가 비어있음"); + return; + } + + console.log(`📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? '그룹 레코드' : '단일 레코드'} (${dataArray.length}개)`); + console.log("📝 [SelectedItemsDetailInput] formData (JSON):", JSON.stringify(dataArray, null, 2)); + + const groups = componentConfig.fieldGroups || []; + const additionalFields = componentConfig.additionalFields || []; + + // 🆕 첫 번째 레코드의 originalData를 기본 항목으로 설정 + const firstRecord = dataArray[0]; + const mainFieldGroups: Record = {}; + + // 🔧 각 그룹별로 고유한 엔트리만 수집 (중복 제거) + groups.forEach((group) => { + const groupFields = additionalFields.filter((field: any) => field.groupId === group.id); + + if (groupFields.length === 0) { + mainFieldGroups[group.id] = []; + return; + } + + // 🆕 각 레코드에서 그룹 데이터 추출 + const entriesMap = new Map(); + + dataArray.forEach((record) => { + const entryData: Record = {}; + + groupFields.forEach((field: any) => { + let fieldValue = record[field.name]; + if (fieldValue !== undefined && fieldValue !== null) { + // 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거) + if (field.type === "date" || field.type === "datetime") { + const dateStr = String(fieldValue); + const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + const [, year, month, day] = match; + fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거) + } + } + entryData[field.name] = fieldValue; + } + }); + + // 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준) + const entryKey = JSON.stringify(entryData); + + if (!entriesMap.has(entryKey)) { + entriesMap.set(entryKey, { + id: `${group.id}_entry_${entriesMap.size + 1}`, + ...entryData, + }); + } + }); + + mainFieldGroups[group.id] = Array.from(entriesMap.values()); + }); + + // 그룹이 없으면 기본 그룹 생성 + if (groups.length === 0) { + mainFieldGroups["default"] = []; + } + + const newItem: ItemData = { + id: String(firstRecord.id || firstRecord.item_id || "edit"), + originalData: firstRecord, // 첫 번째 레코드를 대표 데이터로 사용 + fieldGroups: mainFieldGroups, + }; + + setItems([newItem]); + + console.log("✅ [SelectedItemsDetailInput] 수정 모드 데이터 로드 완료:", { + recordCount: dataArray.length, + item: newItem, + fieldGroupsKeys: Object.keys(mainFieldGroups), + firstGroupEntries: mainFieldGroups[groups[0]?.id]?.length || 0, + }); + return; + } + + // 생성 모드: modalData에서 데이터 로드 if (modalData && modalData.length > 0) { console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData); @@ -253,11 +345,11 @@ export const SelectedItemsDetailInputComponent: React.FC { - const handleSaveRequest = (event: Event) => { + const handleSaveRequest = async (event: Event) => { // component.id를 문자열로 안전하게 변환 const componentKey = String(component.id || "selected_items"); @@ -269,7 +361,88 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { + if (items.length === 0) { + console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음"); + return; + } + + // 🆕 수정 모드인지 확인 (URL에 mode=edit이 있으면) + const urlParams = new URLSearchParams(window.location.search); + const mode = urlParams.get("mode"); + const isEditMode = mode === "edit"; + + console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode }); + + if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) { + // 🔄 수정 모드: UPSERT API 사용 + try { + console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작"); + + // 부모 키 추출 (parentDataMapping에서) + const parentKeys: Record = {}; + + // formData 또는 items[0].originalData에서 부모 데이터 가져오기 + const sourceData = formData || items[0]?.originalData || {}; + + componentConfig.parentDataMapping.forEach((mapping) => { + const value = sourceData[mapping.sourceField]; + if (value !== undefined && value !== null) { + parentKeys[mapping.targetField] = value; + } + }); + + console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys); + + // items를 Cartesian Product로 변환 + const records = generateCartesianProduct(items); + + console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", { + parentKeys, + recordCount: records.length, + records, + }); + + // UPSERT API 호출 + const { dataApi } = await import("@/lib/api/data"); + const result = await dataApi.upsertGroupedRecords( + componentConfig.targetTable || "", + parentKeys, + records + ); + + if (result.success) { + console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", { + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + + // 저장 성공 이벤트 발생 + window.dispatchEvent(new CustomEvent("formSaveSuccess", { + detail: { message: "데이터가 저장되었습니다." }, + })); + } else { + console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error); + window.dispatchEvent(new CustomEvent("formSaveError", { + detail: { message: result.error || "데이터 저장 실패" }, + })); + } + + // event.preventDefault() 역할 + if (event instanceof CustomEvent && event.detail) { + event.detail.skipDefaultSave = true; // 기본 저장 로직 건너뛰기 + } + + } catch (error) { + console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error); + window.dispatchEvent(new CustomEvent("formSaveError", { + detail: { message: "데이터 저장 중 오류가 발생했습니다." }, + })); + } + } else { + // 📝 생성 모드: 기존 로직 (Cartesian Product 생성 후 formData에 추가) + console.log("📝 [SelectedItemsDetailInput] 생성 모드: 기존 저장 로직 사용"); + console.log("📝 [SelectedItemsDetailInput] 저장 데이터 준비:", { key: componentKey, itemsCount: items.length, @@ -287,22 +460,16 @@ export const SelectedItemsDetailInputComponent: React.FC 0, - hasCallback: !!onFormDataChange, - itemsLength: items.length, - }); } }; // 저장 버튼 클릭 시 데이터 수집 - window.addEventListener("beforeFormSave", handleSaveRequest); + window.addEventListener("beforeFormSave", handleSaveRequest as EventListener); return () => { - window.removeEventListener("beforeFormSave", handleSaveRequest); + window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; - }, [items, component.id, onFormDataChange]); + }, [items, component.id, onFormDataChange, componentConfig, formData]); // 스타일 계산 const componentStyle: React.CSSProperties = { @@ -768,7 +935,22 @@ export const SelectedItemsDetailInputComponent: React.FC entry[f.name] || "-").join(" / "); + return fields.map((f) => { + const value = entry[f.name]; + if (!value) return "-"; + + const strValue = String(value); + + // 🔧 ISO 날짜 형식 자동 감지 및 포맷팅 (필드 타입 무관) + // ISO 8601 형식: YYYY-MM-DDTHH:mm:ss.sssZ 또는 YYYY-MM-DD + const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); + if (isoDateMatch) { + const [, year, month, day] = isoDateMatch; + return `${year}.${month}.${day}`; + } + + return strValue; + }).join(" / "); } // displayItems 설정대로 렌더링 @@ -856,13 +1038,22 @@ export const SelectedItemsDetailInputComponent: React.FC setIsLoadingRight(true); try { if (relationshipType === "detail") { - // 상세 모드: 동일 테이블의 상세 정보 + // 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화) const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0]; - const detail = await dataApi.getRecordDetail(rightTableName, primaryKey); + + // 🆕 엔티티 조인 API 사용 + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + search: { id: primaryKey }, + enableEntityJoin: true, // 엔티티 조인 활성화 + size: 1, + }); + + const detail = result.items && result.items.length > 0 ? result.items[0] : null; setRightData(detail); } else if (relationshipType === "join") { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) @@ -388,6 +397,9 @@ export const SplitPanelLayoutComponent: React.FC rightColumn, leftValue, componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달 + true, // 🆕 Entity 조인 활성화 + componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등) + componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) } @@ -754,12 +766,91 @@ export const SplitPanelLayoutComponent: React.FC ); // 수정 버튼 핸들러 - const handleEditClick = useCallback((panel: "left" | "right", item: any) => { - setEditModalPanel(panel); - setEditModalItem(item); - setEditModalFormData({ ...item }); - setShowEditModal(true); - }, []); + const handleEditClick = useCallback( + (panel: "left" | "right", item: any) => { + // 🆕 우측 패널 수정 버튼 설정 확인 + if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") { + const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId; + + if (modalScreenId) { + // 커스텀 모달 화면 열기 + const rightTableName = componentConfig.rightPanel?.tableName || ""; + + // Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) + let primaryKeyName = "id"; + let primaryKeyValue: any; + + if (item.id !== undefined && item.id !== null) { + primaryKeyName = "id"; + primaryKeyValue = item.id; + } else if (item.ID !== undefined && item.ID !== null) { + primaryKeyName = "ID"; + primaryKeyValue = item.ID; + } else { + // 첫 번째 필드를 Primary Key로 간주 + const firstKey = Object.keys(item)[0]; + primaryKeyName = firstKey; + primaryKeyValue = item[firstKey]; + } + + console.log(`✅ 수정 모달 열기:`, { + tableName: rightTableName, + primaryKeyName, + primaryKeyValue, + screenId: modalScreenId, + fullItem: item, + }); + + // modalDataStore에도 저장 (호환성 유지) + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + useModalDataStore.getState().setData(rightTableName, [item]); + }); + + // 🆕 groupByColumns 추출 + const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; + + console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { + groupByColumns, + editButtonConfig: componentConfig.rightPanel?.editButton, + hasGroupByColumns: groupByColumns.length > 0, + }); + + // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: modalScreenId, + urlParams: { + mode: "edit", + editId: primaryKeyValue, + tableName: rightTableName, + ...(groupByColumns.length > 0 && { + groupByColumns: JSON.stringify(groupByColumns), + }), + }, + }, + }), + ); + + console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { + screenId: modalScreenId, + editId: primaryKeyValue, + tableName: rightTableName, + groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", + }); + + return; + } + } + + // 기존 자동 편집 모드 (인라인 편집 모달) + setEditModalPanel(panel); + setEditModalItem(item); + setEditModalFormData({ ...item }); + setShowEditModal(true); + }, + [componentConfig], + ); // 수정 모달 저장 const handleEditModalSave = useCallback(async () => { @@ -1850,16 +1941,20 @@ export const SplitPanelLayoutComponent: React.FC {!isDesignMode && (
- + {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( + + )}
{/* 수정 버튼 */} - {!isDesignMode && ( - + + {componentConfig.rightPanel?.editButton?.buttonLabel || "수정"} + )} {/* 삭제 버튼 */} {!isDesignMode && ( @@ -2011,22 +2191,43 @@ export const SplitPanelLayoutComponent: React.FC
- {allValues.map(([key, value]) => ( - - - - - ))} + {allValues.map(([key, value, label]) => { + // 포맷 설정 찾기 + const colConfig = rightColumns?.find(c => c.name === key); + const format = colConfig?.format; + + // 숫자 포맷 적용 + let displayValue = String(value); + if (value !== null && value !== undefined && value !== "" && format) { + const numValue = typeof value === 'number' ? value : parseFloat(String(value)); + if (!isNaN(numValue)) { + displayValue = numValue.toLocaleString('ko-KR', { + minimumFractionDigits: format.decimalPlaces ?? 0, + maximumFractionDigits: format.decimalPlaces ?? 10, + useGrouping: format.thousandSeparator ?? false, + }); + if (format.prefix) displayValue = format.prefix + displayValue; + if (format.suffix) displayValue = displayValue + format.suffix; + } + } + + return ( + + + + + ); + })}
- {getColumnLabel(key)} - {String(value)}
+ {label || getColumnLabel(key)} + {displayValue}
)}
- ); - })} + ); + })}
) : (
@@ -2045,33 +2246,52 @@ export const SplitPanelLayoutComponent: React.FC // 상세 모드: 단일 객체를 상세 정보로 표시 (() => { const rightColumns = componentConfig.rightPanel?.columns; - let displayEntries: [string, any][] = []; + let displayEntries: [string, any, string][] = []; if (rightColumns && rightColumns.length > 0) { + console.log("🔍 [디버깅] 상세 모드 표시 로직:"); + console.log(" 📋 rightData 전체:", rightData); + console.log(" 📋 rightData keys:", Object.keys(rightData)); + console.log(" ⚙️ 설정된 컬럼:", rightColumns.map((c) => `${c.name} (${c.label})`)); + // 설정된 컬럼만 표시 displayEntries = rightColumns - .map((col) => [col.name, rightData[col.name]] as [string, any]) - .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + .map((col) => { + // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) + let value = rightData[col.name]; + console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`); + + if (value === undefined && col.name.includes('.')) { + const columnName = col.name.split('.').pop(); + value = rightData[columnName || '']; + console.log(` → 변환 후 "${columnName}" 접근 = ${value}`); + } + + return [col.name, value, col.label] as [string, any, string]; + }) + .filter(([key, value]) => { + const filtered = value === null || value === undefined || value === ""; + if (filtered) { + console.log(` ❌ 필터링됨: "${key}" (값: ${value})`); + } + return !filtered; + }); - console.log("🔍 상세 모드 표시 로직:"); - console.log( - " ✅ 설정된 컬럼 사용:", - rightColumns.map((c) => c.name), - ); + console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개"); } else { // 설정 없으면 모든 컬럼 표시 - displayEntries = Object.entries(rightData).filter( - ([_, value]) => value !== null && value !== undefined && value !== "", - ); + displayEntries = Object.entries(rightData) + .filter(([_, value]) => value !== null && value !== undefined && value !== "") + .map(([key, value]) => [key, value, ""] as [string, any, string]); console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시"); } return (
- {displayEntries.map(([key, value]) => ( + {displayEntries.map(([key, value, label]) => (
- {getColumnLabel(key)} + {label || getColumnLabel(key)}
{String(value)}
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 57ab7c33..f59a16e6 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -4,13 +4,14 @@ import React, { useState, useMemo, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; // Accordion 제거 - 단순 섹션으로 변경 -import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react"; +import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { SplitPanelLayoutConfig } from "./types"; import { TableInfo, ColumnInfo } from "@/types/screen"; @@ -24,6 +25,174 @@ interface SplitPanelLayoutConfigPanelProps { screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용) } +/** + * 그룹핑 기준 컬럼 선택 컴포넌트 + */ +const GroupByColumnsSelector: React.FC<{ + tableName?: string; + selectedColumns: string[]; + onChange: (columns: string[]) => void; +}> = ({ tableName, selectedColumns, onChange }) => { + const [columns, setColumns] = useState([]); // ColumnTypeInfo 타입 + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!tableName) { + setColumns([]); + return; + } + + const loadColumns = async () => { + setLoading(true); + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data && response.data.columns) { + setColumns(response.data.columns); + } + } catch (error) { + console.error("컬럼 정보 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + loadColumns(); + }, [tableName]); + + const toggleColumn = (columnName: string) => { + const newSelection = selectedColumns.includes(columnName) + ? selectedColumns.filter((c) => c !== columnName) + : [...selectedColumns, columnName]; + onChange(newSelection); + }; + + if (!tableName) { + return ( +
+

+ 먼저 우측 패널의 테이블을 선택하세요 +

+
+ ); + } + + return ( +
+ + {loading ? ( +
+

로딩 중...

+
+ ) : columns.length === 0 ? ( +
+

컬럼을 찾을 수 없습니다

+
+ ) : ( +
+ {columns.map((col) => ( +
+ toggleColumn(col.columnName)} + /> + +
+ ))} +
+ )} +

+ 선택된 컬럼: {selectedColumns.length > 0 ? selectedColumns.join(", ") : "없음"} +
+ 같은 값을 가진 모든 레코드를 함께 불러옵니다 +

+
+ ); +}; + +/** + * 화면 선택 Combobox 컴포넌트 + */ +const ScreenSelector: React.FC<{ + value?: number; + onChange: (screenId?: number) => void; +}> = ({ value, onChange }) => { + const [open, setOpen] = useState(false); + const [screens, setScreens] = useState>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadScreens = async () => { + setLoading(true); + try { + const { screenApi } = await import("@/lib/api/screen"); + const response = await screenApi.getScreens({ page: 1, size: 1000 }); + setScreens(response.data.map((s) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode }))); + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }; + loadScreens(); + }, []); + + const selectedScreen = screens.find((s) => s.screenId === value); + + return ( + + + + + + + + + 화면을 찾을 수 없습니다. + + {screens.map((screen) => ( + { + onChange(screen.screenId === value ? undefined : screen.screenId); + setOpen(false); + }} + className="text-xs" + > + +
+ {screen.screenName} + {screen.screenCode} +
+
+ ))} +
+
+
+
+
+ ); +}; + /** * SplitPanelLayout 설정 패널 */ @@ -39,6 +208,9 @@ export const SplitPanelLayoutConfigPanel: React.FC>({}); const [loadingColumns, setLoadingColumns] = useState>({}); const [allTables, setAllTables] = useState([]); // 조인 모드용 전체 테이블 목록 + // 엔티티 참조 테이블 컬럼 + type EntityRefTable = { tableName: string; columns: ColumnInfo[] }; + const [entityReferenceTables, setEntityReferenceTables] = useState>({}); // 관계 타입 const relationshipType = config.rightPanel?.relation?.type || "detail"; @@ -158,10 +330,16 @@ export const SplitPanelLayoutConfigPanel: React.FC ({ ...prev, [tableName]: columns })); + + // 🆕 엔티티 타입 컬럼의 참조 테이블 컬럼도 로드 + await loadEntityReferenceColumns(tableName, columns); } catch (error) { console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error); setLoadedTableColumns((prev) => ({ ...prev, [tableName]: [] })); @@ -169,6 +347,59 @@ export const SplitPanelLayoutConfigPanel: React.FC ({ ...prev, [tableName]: false })); } }; + + // 🆕 엔티티 참조 테이블의 컬럼 로드 + const loadEntityReferenceColumns = async (sourceTableName: string, columns: ColumnInfo[]) => { + const entityColumns = columns.filter( + col => (col.input_type === 'entity' || col.webType === 'entity') && col.referenceTable + ); + + if (entityColumns.length === 0) { + return; + } + + console.log(`🔗 테이블 ${sourceTableName}의 엔티티 참조 ${entityColumns.length}개 발견:`, + entityColumns.map(c => `${c.columnName} -> ${c.referenceTable}`) + ); + + const referenceTableData: Array<{tableName: string, columns: ColumnInfo[]}> = []; + + // 각 참조 테이블의 컬럼 로드 + for (const entityCol of entityColumns) { + const refTableName = entityCol.referenceTable!; + + // 이미 로드했으면 스킵 + if (referenceTableData.some(t => t.tableName === refTableName)) continue; + + try { + const refColumnsResponse = await tableTypeApi.getColumns(refTableName); + const refColumns: ColumnInfo[] = (refColumnsResponse || []).map((col: any) => ({ + tableName: col.tableName || refTableName, + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type || col.dbType, + input_type: col.inputType || col.input_type, + })); + + referenceTableData.push({ tableName: refTableName, columns: refColumns }); + console.log(` ✅ 참조 테이블 ${refTableName} 컬럼 ${refColumns.length}개 로드됨`); + } catch (error) { + console.error(` ❌ 참조 테이블 ${refTableName} 컬럼 로드 실패:`, error); + } + } + + // 참조 테이블 정보 저장 + setEntityReferenceTables(prev => ({ + ...prev, + [sourceTableName]: referenceTableData + })); + + console.log(`✅ [엔티티 참조] ${sourceTableName}의 참조 테이블 저장 완료:`, { + sourceTableName, + referenceTableCount: referenceTableData.length, + referenceTables: referenceTableData.map(t => `${t.tableName}(${t.columns.length}개)`), + }); + }; // 좌측/우측 테이블이 변경되면 해당 테이블의 컬럼 로드 useEffect(() => { @@ -253,17 +484,21 @@ export const SplitPanelLayoutConfigPanel: React.FC { - const tableName = config.leftPanel?.tableName || screenTableName; - return tableName ? loadedTableColumns[tableName] || [] : []; - }, [loadedTableColumns, config.leftPanel?.tableName, screenTableName]); + return leftTableName ? loadedTableColumns[leftTableName] || [] : []; + }, [loadedTableColumns, leftTableName]); + // 우측 테이블명 + const rightTableName = config.rightPanel?.tableName || ""; + // 우측 테이블 컬럼 (로드된 컬럼 사용) const rightTableColumns = useMemo(() => { - const tableName = config.rightPanel?.tableName; - return tableName ? loadedTableColumns[tableName] || [] : []; - }, [loadedTableColumns, config.rightPanel?.tableName]); + return rightTableName ? loadedTableColumns[rightTableName] || [] : []; + }, [loadedTableColumns, rightTableName]); // 테이블 데이터 로딩 상태 확인 if (!tables || tables.length === 0) { @@ -737,6 +972,41 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ {/* 순서 변경 버튼 */} +
+ + +
+
@@ -745,7 +1015,7 @@ export const SplitPanelLayoutConfigPanel: React.FC - {col.name || "컬럼 선택"} + {col.label || col.name || "컬럼 선택"} @@ -753,35 +1023,78 @@ export const SplitPanelLayoutConfigPanel: React.FC 컬럼을 찾을 수 없습니다. - - {leftTableColumns.map((column) => ( - { - const newColumns = [...(config.leftPanel?.columns || [])]; - newColumns[index] = { - ...newColumns[index], - name: value, - label: column.columnLabel || value, - }; - updateLeftPanel({ columns: newColumns }); - }} - className="text-xs" - > - - {column.columnLabel || column.columnName} - - ({column.columnName}) - - +
+ {/* 기본 테이블 컬럼 */} + + {leftTableColumns.map((column) => ( + { + const newColumns = [...(config.leftPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateLeftPanel({ columns: newColumns }); + // Popover 닫기 + document.body.click(); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + {/* 🆕 엔티티 참조 테이블 컬럼 */} + {leftTableName && entityReferenceTables[leftTableName]?.map((refTable) => ( + + {refTable.columns.map((column) => { + const fullColumnName = `${refTable.tableName}.${column.columnName}`; + return ( + { + const newColumns = [...(config.leftPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || column.columnName, + }; + updateLeftPanel({ columns: newColumns }); + // Popover 닫기 + document.body.click(); + }} + className="text-xs pl-6" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ); + })} + ))} - +
@@ -1133,6 +1446,44 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ {/* 요약 표시 설정 (LIST 모드에서만) */} + {config.rightPanel?.displayMode === "list" && ( +
+ + +
+ + { + const value = parseInt(e.target.value) || 3; + updateRightPanel({ summaryColumnCount: value }); + }} + className="bg-white" + /> +

+ 접기 전에 표시할 컬럼 개수 (기본: 3개) +

+
+ +
+
+ +

컬럼명 표시 여부

+
+ { + updateRightPanel({ summaryShowLabel: checked as boolean }); + }} + /> +
+
+ )} + {/* 컬럼 매핑 - 조인 모드에서만 표시 */} {relationshipType !== "detail" && (
@@ -1304,6 +1655,41 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ {/* 순서 변경 버튼 */} +
+ + +
+
@@ -1312,7 +1698,7 @@ export const SplitPanelLayoutConfigPanel: React.FC - {col.name || "컬럼 선택"} + {col.label || col.name || "컬럼 선택"} @@ -1320,35 +1706,78 @@ export const SplitPanelLayoutConfigPanel: React.FC 컬럼을 찾을 수 없습니다. - - {rightTableColumns.map((column) => ( - { - const newColumns = [...(config.rightPanel?.columns || [])]; - newColumns[index] = { - ...newColumns[index], - name: value, - label: column.columnLabel || value, - }; - updateRightPanel({ columns: newColumns }); - }} - className="text-xs" - > - - {column.columnLabel || column.columnName} - - ({column.columnName}) - - +
+ {/* 기본 테이블 컬럼 */} + + {rightTableColumns.map((column) => ( + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateRightPanel({ columns: newColumns }); + // Popover 닫기 + document.body.click(); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + {/* 🆕 엔티티 참조 테이블 컬럼 */} + {rightTableName && entityReferenceTables[rightTableName]?.map((refTable) => ( + + {refTable.columns.map((column) => { + const fullColumnName = `${refTable.tableName}.${column.columnName}`; + return ( + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || column.columnName, + }; + updateRightPanel({ columns: newColumns }); + // Popover 닫기 + document.body.click(); + }} + className="text-xs pl-6" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ); + })} + ))} - +
@@ -1431,6 +1860,150 @@ export const SplitPanelLayoutConfigPanel: React.FC
)} + + {/* LIST 모드: 볼드 설정 */} + {!isTableMode && ( +
+ + +
+ )} + + {/* 🆕 숫자 타입 포맷 설정 */} + {(() => { + // 컬럼 타입 확인 + const column = rightTableColumns.find(c => c.columnName === col.name); + const isNumeric = column && ['numeric', 'decimal', 'integer', 'bigint', 'double precision', 'real'].includes(column.dataType?.toLowerCase() || ''); + + if (!isNumeric) return null; + + return ( +
+ + +
+ {/* 천 단위 구분자 */} + + + {/* 소수점 자릿수 */} +
+ + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + format: { + ...newColumns[index].format, + decimalPlaces: e.target.value ? parseInt(e.target.value) : undefined, + }, + }; + updateRightPanel({ columns: newColumns }); + }} + className="h-7 text-xs" + /> +
+
+ +
+ {/* 접두사 */} +
+ + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + format: { + ...newColumns[index].format, + prefix: e.target.value || undefined, + }, + }; + updateRightPanel({ columns: newColumns }); + }} + className="h-7 text-xs" + /> +
+ + {/* 접미사 */} +
+ + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + format: { + ...newColumns[index].format, + suffix: e.target.value || undefined, + }, + }; + updateRightPanel({ columns: newColumns }); + }} + className="h-7 text-xs" + /> +
+
+ + {/* 미리보기 */} + {(col.format?.thousandSeparator || col.format?.prefix || col.format?.suffix || col.format?.decimalPlaces !== undefined) && ( +
+

미리보기:

+

+ {col.format?.prefix || ''} + {(1234567.89).toLocaleString('ko-KR', { + minimumFractionDigits: col.format?.decimalPlaces ?? 0, + maximumFractionDigits: col.format?.decimalPlaces ?? 10, + useGrouping: col.format?.thousandSeparator ?? false, + })} + {col.format?.suffix || ''} +

+
+ )} +
+ ); + })()}
); }) @@ -1700,6 +2273,272 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ {/* 우측 패널 중복 제거 */} +
+
+
+

중복 데이터 제거

+

+ 같은 값을 가진 데이터를 하나로 통합하여 표시 +

+
+ { + if (checked) { + updateRightPanel({ + deduplication: { + enabled: true, + groupByColumn: "", + keepStrategy: "latest", + sortColumn: "start_date", + }, + }); + } else { + updateRightPanel({ deduplication: undefined }); + } + }} + /> +
+ + {config.rightPanel?.deduplication?.enabled && ( +
+ {/* 중복 제거 기준 컬럼 */} +
+ + +

+ 이 컬럼의 값이 같은 데이터들 중 하나만 표시합니다 +

+
+ + {/* 유지 전략 */} +
+ + +

+ {config.rightPanel?.deduplication?.keepStrategy === "latest" && "가장 최근에 추가된 데이터를 표시합니다"} + {config.rightPanel?.deduplication?.keepStrategy === "earliest" && "가장 먼저 추가된 데이터를 표시합니다"} + {config.rightPanel?.deduplication?.keepStrategy === "current_date" && "오늘 날짜 기준으로 유효한 기간의 데이터를 표시합니다"} + {config.rightPanel?.deduplication?.keepStrategy === "base_price" && "기준단가(base_price)로 체크된 데이터를 표시합니다"} +

+
+ + {/* 정렬 기준 컬럼 (latest/earliest만) */} + {(config.rightPanel?.deduplication?.keepStrategy === "latest" || + config.rightPanel?.deduplication?.keepStrategy === "earliest") && ( +
+ + +

+ 이 컬럼의 값으로 최신/최초를 판단합니다 (보통 날짜 컬럼) +

+
+ )} +
+ )} +
+ + {/* 🆕 우측 패널 수정 버튼 설정 */} +
+
+
+

수정 버튼 설정

+

+ 우측 리스트의 수정 버튼 동작 방식 설정 +

+
+ { + updateRightPanel({ + editButton: { + enabled: checked, + mode: config.rightPanel?.editButton?.mode || "auto", + buttonLabel: config.rightPanel?.editButton?.buttonLabel, + buttonVariant: config.rightPanel?.editButton?.buttonVariant, + }, + }); + }} + /> +
+ + {(config.rightPanel?.editButton?.enabled ?? true) && ( +
+ {/* 수정 모드 */} +
+ + +

+ {config.rightPanel?.editButton?.mode === "modal" + ? "지정한 화면을 모달로 열어 데이터를 수정합니다" + : "현재 위치에서 직접 데이터를 수정합니다"} +

+
+ + {/* 모달 화면 선택 (modal 모드일 때만) */} + {config.rightPanel?.editButton?.mode === "modal" && ( +
+ + + updateRightPanel({ + editButton: { + ...config.rightPanel?.editButton!, + modalScreenId: screenId, + }, + }) + } + /> +

+ 수정 버튼 클릭 시 열릴 화면을 선택하세요 +

+
+ )} + + {/* 버튼 라벨 */} +
+ + + updateRightPanel({ + editButton: { + ...config.rightPanel?.editButton!, + buttonLabel: e.target.value, + enabled: config.rightPanel?.editButton?.enabled ?? true, + mode: config.rightPanel?.editButton?.mode || "auto", + }, + }) + } + className="h-8 text-xs" + placeholder="수정" + /> +
+ + {/* 버튼 스타일 */} +
+ + +
+ + {/* 🆕 그룹핑 기준 컬럼 설정 (modal 모드일 때만 표시) */} + {config.rightPanel?.editButton?.mode === "modal" && ( + { + updateRightPanel({ + editButton: { + ...config.rightPanel?.editButton!, + groupByColumns: columns, + enabled: config.rightPanel?.editButton?.enabled ?? true, + mode: config.rightPanel?.editButton?.mode || "auto", + }, + }); + }} + /> + )} +
+ )} +
+ {/* 레이아웃 설정 */}
diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index 95d98085..e5471566 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -21,6 +21,14 @@ export interface SplitPanelLayoutConfig { width?: number; sortable?: boolean; // 정렬 가능 여부 (테이블 모드) align?: "left" | "center" | "right"; // 정렬 (테이블 모드) + format?: { + type?: "number" | "currency" | "date" | "text"; // 포맷 타입 + thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency") + decimalPlaces?: number; // 소수점 자릿수 + prefix?: string; // 접두사 (예: "₩", "$") + suffix?: string; // 접미사 (예: "원", "개") + dateFormat?: string; // 날짜 포맷 (type: "date") + }; }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{ @@ -69,12 +77,23 @@ export interface SplitPanelLayoutConfig { showAdd?: boolean; showEdit?: boolean; // 수정 버튼 showDelete?: boolean; // 삭제 버튼 + summaryColumnCount?: number; // 요약에서 표시할 컬럼 개수 (기본: 3) + summaryShowLabel?: boolean; // 요약에서 라벨 표시 여부 (기본: true) columns?: Array<{ name: string; label: string; width?: number; sortable?: boolean; // 정렬 가능 여부 (테이블 모드) align?: "left" | "center" | "right"; // 정렬 (테이블 모드) + bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드) + format?: { + type?: "number" | "currency" | "date" | "text"; // 포맷 타입 + thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency") + decimalPlaces?: number; // 소수점 자릿수 + prefix?: string; // 접두사 (예: "₩", "$") + suffix?: string; // 접미사 (예: "원", "개") + dateFormat?: string; // 날짜 포맷 (type: "date") + }; }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{ @@ -113,6 +132,24 @@ export interface SplitPanelLayoutConfig { // 🆕 컬럼 값 기반 데이터 필터링 dataFilter?: DataFilterConfig; + + // 🆕 중복 제거 설정 + deduplication?: { + enabled: boolean; // 중복 제거 활성화 + groupByColumn: string; // 중복 제거 기준 컬럼 (예: "item_id") + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; // 어떤 행을 유지할지 + sortColumn?: string; // keepStrategy가 latest/earliest일 때 정렬 기준 컬럼 + }; + + // 🆕 수정 버튼 설정 + editButton?: { + enabled: boolean; // 수정 버튼 표시 여부 (기본: true) + mode: "auto" | "modal"; // auto: 자동 편집 (인라인), modal: 커스텀 모달 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "수정") + buttonVariant?: "default" | "outline" | "ghost"; // 버튼 스타일 (기본: "outline") + groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"]) + }; }; // 레이아웃 설정 diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index a1dbd99a..195b9b61 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -446,9 +446,29 @@ export interface DataTableFilter { export interface ColumnFilter { id: string; columnName: string; // 필터링할 컬럼명 - operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null"; - value: string | string[]; // 필터 값 (in/not_in은 배열) - valueType: "static" | "category" | "code"; // 값 타입 + operator: + | "equals" + | "not_equals" + | "in" + | "not_in" + | "contains" + | "starts_with" + | "ends_with" + | "is_null" + | "is_not_null" + | "greater_than" + | "less_than" + | "greater_than_or_equal" + | "less_than_or_equal" + | "between" + | "date_range_contains"; // 날짜 범위 포함 (start_date <= value <= end_date) + value: string | string[]; // 필터 값 (in/not_in은 배열, date_range_contains는 비교할 날짜) + valueType: "static" | "category" | "code" | "dynamic"; // 값 타입 (dynamic: 현재 날짜 등) + // date_range_contains 전용 설정 + rangeConfig?: { + startColumn: string; // 시작일 컬럼명 + endColumn: string; // 종료일 컬럼명 + }; } /** -- 2.43.0 From e3b78309faebf8a42a938966968defd968a3affb Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 11:58:43 +0900 Subject: [PATCH 7/9] =?UTF-8?q?=EC=9A=B0=EC=B8=A1=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/dataRoutes.ts | 142 ++++++++----- backend-node/src/services/dataService.ts | 192 +++++++++++++++--- .../src/services/entityJoinService.ts | 71 ++++++- frontend/components/common/ScreenModal.tsx | 43 +++- frontend/lib/api/data.ts | 46 ++++- .../SelectedItemsDetailInputComponent.tsx | 154 ++++++++++++-- .../SplitPanelLayoutComponent.tsx | 44 +++- 7 files changed, 587 insertions(+), 105 deletions(-) diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index b7ba4983..c696d5de 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -393,6 +393,86 @@ router.get( } ); +/** + * 그룹화된 데이터 UPSERT API + * POST /api/data/upsert-grouped + * + * 요청 본문: + * { + * tableName: string, + * parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }, + * records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ] + * } + */ +router.post( + "/upsert-grouped", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, parentKeys, records } = req.body; + + // 입력값 검증 + if (!tableName || !parentKeys || !records || !Array.isArray(records)) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).", + error: "MISSING_PARAMETERS", + }); + } + + // 테이블명 검증 + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`🔄 그룹화된 데이터 UPSERT: ${tableName}`, { + parentKeys, + recordCount: records.length, + userCompany: req.user?.companyCode, + userId: req.user?.userId, + }); + + // UPSERT 수행 + const result = await dataService.upsertGroupedRecords( + tableName, + parentKeys, + records, + req.user?.companyCode, + req.user?.userId + ); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, { + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + + return res.json({ + success: true, + message: "데이터가 저장되었습니다.", + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + } catch (error) { + console.error("그룹화된 데이터 UPSERT 오류:", error); + return res.status(500).json({ + success: false, + message: "데이터 저장 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + /** * 레코드 생성 API * POST /api/data/{tableName} @@ -579,76 +659,40 @@ router.post( ); /** - * 그룹화된 데이터 UPSERT API - * POST /api/data/upsert-grouped - * - * 요청 본문: - * { - * tableName: string, - * parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }, - * records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ] - * } + * 그룹 삭제 API + * POST /api/data/:tableName/delete-group */ router.post( - "/upsert-grouped", + "/:tableName/delete-group", authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { tableName, parentKeys, records } = req.body; + const { tableName } = req.params; + const filterConditions = req.body; - // 입력값 검증 - if (!tableName || !parentKeys || !records || !Array.isArray(records)) { - return res.status(400).json({ - success: false, - message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).", - error: "MISSING_PARAMETERS", - }); - } - - // 테이블명 검증 if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { return res.status(400).json({ success: false, message: "유효하지 않은 테이블명입니다.", - error: "INVALID_TABLE_NAME", }); } - console.log(`🔄 그룹화된 데이터 UPSERT: ${tableName}`, { - parentKeys, - recordCount: records.length, - }); + console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); - // UPSERT 수행 - const result = await dataService.upsertGroupedRecords( - tableName, - parentKeys, - records - ); + const result = await dataService.deleteGroupRecords(tableName, filterConditions); if (!result.success) { return res.status(400).json(result); } - console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, { - inserted: result.inserted, - updated: result.updated, - deleted: result.deleted, - }); - - return res.json({ - success: true, - message: "데이터가 저장되었습니다.", - inserted: result.inserted, - updated: result.updated, - deleted: result.deleted, - }); - } catch (error) { - console.error("그룹화된 데이터 UPSERT 오류:", error); + console.log(`✅ 그룹 삭제: ${result.data?.deleted}개`); + return res.json(result); + } catch (error: any) { + console.error("그룹 삭제 오류:", error); return res.status(500).json({ success: false, - message: "데이터 저장 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", + message: "그룹 삭제 실패", + error: error.message, }); } } diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index dd40432e..d9b13475 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -16,6 +16,7 @@ import { query, queryOne } from "../database/db"; import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸 +import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성 interface GetTableDataParams { tableName: string; @@ -530,7 +531,27 @@ class DataService { }; } - console.log(`✅ Entity Join 데이터 조회 성공:`, result.rows[0]); + // 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환 + const normalizeDates = (rows: any[]) => { + return rows.map(row => { + const normalized: any = {}; + for (const [key, value] of Object.entries(row)) { + if (value instanceof Date) { + // Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시) + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + normalized[key] = `${year}-${month}-${day}`; + } else { + normalized[key] = value; + } + } + return normalized; + }); + }; + + const normalizedRows = normalizeDates(result.rows); + console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]); // 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회 if (groupByColumns.length > 0) { @@ -542,7 +563,7 @@ class DataService { let paramIndex = 1; for (const col of groupByColumns) { - const value = baseRecord[col]; + const value = normalizedRows[0][col]; if (value !== undefined && value !== null) { groupConditions.push(`main."${col}" = $${paramIndex}`); groupValues.push(value); @@ -565,18 +586,19 @@ class DataService { const groupResult = await pool.query(groupQuery, groupValues); - console.log(`✅ 그룹 레코드 조회 성공: ${groupResult.rows.length}개`); + const normalizedGroupRows = normalizeDates(groupResult.rows); + console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`); return { success: true, - data: groupResult.rows, // 🔧 배열로 반환! + data: normalizedGroupRows, // 🔧 배열로 반환! }; } } return { success: true, - data: result.rows[0], // 그룹핑 없으면 단일 레코드 + data: normalizedRows[0], // 그룹핑 없으면 단일 레코드 }; } } @@ -755,14 +777,33 @@ class DataService { const result = await pool.query(finalQuery, values); - console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${result.rows.length}개`); + // 🔧 날짜 타입 타임존 문제 해결 + const normalizeDates = (rows: any[]) => { + return rows.map(row => { + const normalized: any = {}; + for (const [key, value] of Object.entries(row)) { + if (value instanceof Date) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + normalized[key] = `${year}-${month}-${day}`; + } else { + normalized[key] = value; + } + } + return normalized; + }); + }; + + const normalizedRows = normalizeDates(result.rows); + console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`); // 🆕 중복 제거 처리 - let finalData = result.rows; + let finalData = normalizedRows; if (deduplication?.enabled && deduplication.groupByColumn) { console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); - finalData = this.deduplicateData(result.rows, deduplication); - console.log(`✅ 중복 제거 완료: ${result.rows.length}개 → ${finalData.length}개`); + finalData = this.deduplicateData(normalizedRows, deduplication); + console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`); } return { @@ -1063,6 +1104,53 @@ class DataService { } } + /** + * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) + */ + async deleteGroupRecords( + tableName: string, + filterConditions: Record + ): Promise> { + try { + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; + } + + const whereConditions: string[] = []; + const whereValues: any[] = []; + let paramIndex = 1; + + for (const [key, value] of Object.entries(filterConditions)) { + whereConditions.push(`"${key}" = $${paramIndex}`); + whereValues.push(value); + paramIndex++; + } + + if (whereConditions.length === 0) { + return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" }; + } + + const whereClause = whereConditions.join(" AND "); + const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; + + console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); + + const result = await pool.query(deleteQuery, whereValues); + + console.log(`✅ 그룹 삭제 성공: ${result.rowCount}개`); + + return { success: true, data: { deleted: result.rowCount || 0 } }; + } catch (error) { + console.error("그룹 삭제 오류:", error); + return { + success: false, + message: "그룹 삭제 실패", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + /** * 그룹화된 데이터 UPSERT * - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아 @@ -1072,27 +1160,27 @@ class DataService { async upsertGroupedRecords( tableName: string, parentKeys: Record, - records: Array> + records: Array>, + userCompany?: string, + userId?: string ): Promise> { try { // 테이블 접근 권한 검증 - if (!this.canAccessTable(tableName)) { - return { - success: false, - message: `테이블 '${tableName}'에 접근할 수 없습니다.`, - error: "ACCESS_DENIED", - }; + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 감지 - const pkColumn = await this.detectPrimaryKey(tableName); - if (!pkColumn) { + const pkColumns = await this.getPrimaryKeyColumns(tableName); + if (!pkColumns || pkColumns.length === 0) { return { success: false, message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`, error: "PRIMARY_KEY_NOT_FOUND", }; } + const pkColumn = pkColumns[0]; // 첫 번째 PK 사용 console.log(`🔍 UPSERT 시작: ${tableName}`, { parentKeys, @@ -1125,19 +1213,37 @@ class DataService { let updated = 0; let deleted = 0; + // 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수 + const normalizeDateValue = (value: any): any => { + if (value == null) return value; + + // ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ) + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { + return value.split('T')[0]; // YYYY-MM-DD 만 추출 + } + + return value; + }; + // 새 레코드 처리 (INSERT or UPDATE) for (const newRecord of records) { - // 전체 레코드 데이터 (parentKeys + newRecord) - const fullRecord = { ...parentKeys, ...newRecord }; + // 날짜 필드 정규화 + const normalizedRecord: Record = {}; + for (const [key, value] of Object.entries(newRecord)) { + normalizedRecord[key] = normalizeDateValue(value); + } + + // 전체 레코드 데이터 (parentKeys + normalizedRecord) + const fullRecord = { ...parentKeys, ...normalizedRecord }; // 고유 키: parentKeys 제외한 나머지 필드들 - const uniqueFields = Object.keys(newRecord); + const uniqueFields = Object.keys(normalizedRecord); // 기존 레코드에서 일치하는 것 찾기 const existingRecord = existingRecords.rows.find((existing) => { return uniqueFields.every((field) => { const existingValue = existing[field]; - const newValue = newRecord[field]; + const newValue = normalizedRecord[field]; // null/undefined 처리 if (existingValue == null && newValue == null) return true; @@ -1180,15 +1286,49 @@ class DataService { console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); } else { // INSERT: 기존 레코드가 없으면 삽입 - const insertFields = Object.keys(fullRecord); - const insertPlaceholders = insertFields.map((_, idx) => `$${idx + 1}`); - const insertValues = Object.values(fullRecord); + + // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) + const recordWithMeta: Record = { + ...fullRecord, + id: uuidv4(), // 새 ID 생성 + created_date: "NOW()", + updated_date: "NOW()", + }; + + // company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만) + if (!recordWithMeta.company_code && userCompany && userCompany !== "*") { + recordWithMeta.company_code = userCompany; + } + + // writer가 없으면 userId 사용 + if (!recordWithMeta.writer && userId) { + recordWithMeta.writer = userId; + } + + const insertFields = Object.keys(recordWithMeta).filter(key => + recordWithMeta[key] !== "NOW()" + ); + const insertPlaceholders: string[] = []; + const insertValues: any[] = []; + let insertParamIndex = 1; + + for (const field of Object.keys(recordWithMeta)) { + if (recordWithMeta[field] === "NOW()") { + insertPlaceholders.push("NOW()"); + } else { + insertPlaceholders.push(`$${insertParamIndex}`); + insertValues.push(recordWithMeta[field]); + insertParamIndex++; + } + } const insertQuery = ` - INSERT INTO "${tableName}" (${insertFields.map(f => `"${f}"`).join(", ")}) + INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) `; + console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues }); + await pool.query(insertQuery, insertValues); inserted++; diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 88aca52d..3283ea09 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -200,6 +200,25 @@ export class EntityJoinService { } } + /** + * 날짜 컬럼을 YYYY-MM-DD 형식으로 변환하는 SQL 표현식 + */ + private formatDateColumn( + tableAlias: string, + columnName: string, + dataType?: string + ): string { + // date, timestamp 타입이면 TO_CHAR로 변환 + if ( + dataType && + (dataType.includes("date") || dataType.includes("timestamp")) + ) { + return `TO_CHAR(${tableAlias}.${columnName}, 'YYYY-MM-DD')`; + } + // 기본은 TEXT 캐스팅 + return `${tableAlias}.${columnName}::TEXT`; + } + /** * Entity 조인이 포함된 SQL 쿼리 생성 */ @@ -210,19 +229,30 @@ export class EntityJoinService { whereClause: string = "", orderBy: string = "", limit?: number, - offset?: number + offset?: number, + columnTypes?: Map // 컬럼명 → 데이터 타입 매핑 ): { query: string; aliasMap: Map } { try { - // 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지) - // "*"는 특별 처리: AS 없이 그냥 main.*만 - const baseColumns = selectColumns - .map((col) => { - if (col === "*") { - return "main.*"; - } - return `main.${col}::TEXT AS ${col}`; - }) - .join(", "); + // 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅) + // 🔧 "*"는 전체 조회하되, 날짜 타입 타임존 문제를 피하기 위해 + // jsonb_build_object를 사용하여 명시적으로 변환 + let baseColumns: string; + if (selectColumns.length === 1 && selectColumns[0] === "*") { + // main.* 사용 시 날짜 타입 필드만 TO_CHAR로 변환 + // PostgreSQL의 날짜 → 타임스탬프 자동 변환으로 인한 타임존 문제 방지 + baseColumns = `main.*`; + logger.info( + `⚠️ [buildJoinQuery] main.* 사용 - 날짜 타임존 변환 주의 필요` + ); + } else { + baseColumns = selectColumns + .map((col) => { + const dataType = columnTypes?.get(col); + const formattedCol = this.formatDateColumn("main", col, dataType); + return `${formattedCol} AS ${col}`; + }) + .join(", "); + } // Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리) // 별칭 매핑 생성 (JOIN 절과 동일한 로직) @@ -303,6 +333,13 @@ export class EntityJoinService { resultColumns.push( `COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label` ); + + // 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용) + // 예: customer_code, item_number 등 + // col과 동일해도 별도의 alias로 추가 (customer_code as customer_code) + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` + ); } else { resultColumns.push( `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` @@ -328,6 +365,18 @@ export class EntityJoinService { .join(` || '${separator}' || `); resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`); + + // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; + if ( + isJoinTableColumn && + !displayColumns.includes(config.referenceColumn) + ) { + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` + ); + } } // 모든 resultColumns를 반환 diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 953b6fc1..cf0a5edb 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -297,10 +297,38 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2)); } - setFormData(response.data); + // 🔧 날짜 필드 정규화 (타임존 제거) + const normalizeDates = (data: any): any => { + if (Array.isArray(data)) { + return data.map(normalizeDates); + } + + if (typeof data !== 'object' || data === null) { + return data; + } + + const normalized: any = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { + // ISO 날짜 형식 감지: YYYY-MM-DD만 추출 + const before = value; + const after = value.split('T')[0]; + console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`); + normalized[key] = after; + } else { + normalized[key] = value; + } + } + return normalized; + }; + + console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2)); + const normalizedData = normalizeDates(response.data); + console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2)); + setFormData(normalizedData); // setFormData 직후 확인 - console.log("🔄 setFormData 호출 완료"); + console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)"); } else { console.error("❌ 수정 데이터 로드 실패:", response.error); toast.error("데이터를 불러올 수 없습니다."); @@ -359,6 +387,17 @@ export const ScreenModal: React.FC = ({ className }) => { }; const handleClose = () => { + // 🔧 URL 파라미터 제거 (mode, editId, tableName 등) + if (typeof window !== "undefined") { + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete("mode"); + currentUrl.searchParams.delete("editId"); + currentUrl.searchParams.delete("tableName"); + currentUrl.searchParams.delete("groupByColumns"); + window.history.pushState({}, "", currentUrl.toString()); + console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)"); + } + setModalState({ isOpen: false, screenId: null, diff --git a/frontend/lib/api/data.ts b/frontend/lib/api/data.ts index 72002ad1..8436dcf4 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -169,6 +169,31 @@ export const dataApi = { return response.data; // success, message 포함된 전체 응답 반환 }, + /** + * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) + * @param tableName 테이블명 + * @param filterConditions 삭제 조건 (예: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }) + */ + deleteGroupRecords: async ( + tableName: string, + filterConditions: Record + ): Promise<{ success: boolean; deleted?: number; message?: string; error?: string }> => { + try { + console.log(`🗑️ [dataApi] 그룹 삭제 요청:`, { tableName, filterConditions }); + + const response = await apiClient.post(`/data/${tableName}/delete-group`, filterConditions); + + console.log(`✅ [dataApi] 그룹 삭제 성공:`, response.data); + return response.data; + } catch (error: any) { + console.error(`❌ [dataApi] 그룹 삭제 실패:`, error); + return { + success: false, + error: error.response?.data?.message || error.message || "그룹 삭제 실패", + }; + } + }, + /** * 특정 레코드 상세 조회 * @param tableName 테이블명 @@ -207,13 +232,30 @@ export const dataApi = { records: Array> ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => { try { - const response = await apiClient.post('/data/upsert-grouped', { + console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", { + tableName, + tableNameType: typeof tableName, + tableNameValue: JSON.stringify(tableName), + parentKeys, + recordsCount: records.length, + }); + + const requestBody = { tableName, parentKeys, records, - }); + }; + console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2)); + + const response = await apiClient.post('/data/upsert-grouped', requestBody); return response.data; } catch (error: any) { + console.error("❌ [dataApi.upsertGroupedRecords] 에러:", { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + message: error.message, + }); return { success: false, error: error.response?.data?.message || error.message || "데이터 저장 실패", diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 82abcf20..581c0273 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -257,18 +257,31 @@ export const SelectedItemsDetailInputComponent: React.FC { let fieldValue = record[field.name]; - if (fieldValue !== undefined && fieldValue !== null) { - // 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거) - if (field.type === "date" || field.type === "datetime") { - const dateStr = String(fieldValue); - const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); - if (match) { - const [, year, month, day] = match; - fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거) - } + + // 🔧 값이 없으면 기본값 사용 (false, 0, "" 등 falsy 값도 유효한 값으로 처리) + if (fieldValue === undefined || fieldValue === null) { + // 기본값이 있으면 사용, 없으면 필드 타입에 따라 기본값 설정 + if (field.defaultValue !== undefined) { + fieldValue = field.defaultValue; + } else if (field.type === "checkbox") { + fieldValue = false; // checkbox는 기본값 false + } else { + // 다른 타입은 null로 유지 (필수 필드가 아니면 표시 안 됨) + return; } - entryData[field.name] = fieldValue; } + + // 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거) + if (field.type === "date" || field.type === "datetime") { + const dateStr = String(fieldValue); + const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + const [, year, month, day] = match; + fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거) + } + } + + entryData[field.name] = fieldValue; }); // 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준) @@ -347,6 +360,59 @@ export const SelectedItemsDetailInputComponent: React.FC[] => { + const allRecords: Record[] = []; + const groups = componentConfig.fieldGroups || []; + const additionalFields = componentConfig.additionalFields || []; + + itemsList.forEach((item) => { + // 각 그룹의 엔트리 배열들을 준비 + const groupEntriesArrays: GroupEntry[][] = groups.map(group => item.fieldGroups[group.id] || []); + + // Cartesian Product 재귀 함수 + const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record) => { + if (currentIndex === arrays.length) { + // 모든 그룹을 순회했으면 조합 완성 + allRecords.push({ ...currentCombination }); + return; + } + + const currentGroupEntries = arrays[currentIndex]; + if (currentGroupEntries.length === 0) { + // 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행 + cartesian(arrays, currentIndex + 1, currentCombination); + return; + } + + // 현재 그룹의 각 엔트리마다 재귀 + currentGroupEntries.forEach(entry => { + const newCombination = { ...currentCombination }; + + // 현재 그룹의 필드들을 조합에 추가 + const groupFields = additionalFields.filter(f => f.groupId === groups[currentIndex].id); + groupFields.forEach(field => { + if (entry[field.name] !== undefined) { + newCombination[field.name] = entry[field.name]; + } + }); + + cartesian(arrays, currentIndex + 1, newCombination); + }); + }; + + // 재귀 시작 + cartesian(groupEntriesArrays, 0, {}); + }); + + console.log("🔀 [generateCartesianProduct] 생성된 레코드:", { + count: allRecords.length, + records: allRecords, + }); + + return allRecords; + }, [componentConfig.fieldGroups, componentConfig.additionalFields]); + // 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식) useEffect(() => { const handleSaveRequest = async (event: Event) => { @@ -377,17 +443,40 @@ export const SelectedItemsDetailInputComponent: React.FC = {}; // formData 또는 items[0].originalData에서 부모 데이터 가져오기 - const sourceData = formData || items[0]?.originalData || {}; + // formData가 배열이면 첫 번째 항목 사용 + let sourceData: any = formData; + if (Array.isArray(formData) && formData.length > 0) { + sourceData = formData[0]; + } else if (!formData) { + sourceData = items[0]?.originalData || {}; + } + + console.log("📦 [SelectedItemsDetailInput] 부모 데이터 소스:", { + formDataType: Array.isArray(formData) ? "배열" : typeof formData, + sourceData, + sourceDataKeys: Object.keys(sourceData), + parentDataMapping: componentConfig.parentDataMapping, + }); + + console.log("🔍 [SelectedItemsDetailInput] sourceData 전체 내용 (JSON):", JSON.stringify(sourceData, null, 2)); componentConfig.parentDataMapping.forEach((mapping) => { const value = sourceData[mapping.sourceField]; if (value !== undefined && value !== null) { parentKeys[mapping.targetField] = value; + } else { + console.warn(`⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`); } }); @@ -402,10 +491,28 @@ export const SelectedItemsDetailInputComponent: React.FC { window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; - }, [items, component.id, onFormDataChange, componentConfig, formData]); + }, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct]); // 스타일 계산 const componentStyle: React.CSSProperties = { @@ -1027,6 +1134,15 @@ export const SelectedItemsDetailInputComponent: React.FC - {displayItem.label}{formattedValue} + {displayItem.label}{finalValue} ); } diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ea80a1f9..532ed3a5 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -979,8 +979,50 @@ export const SplitPanelLayoutComponent: React.FC try { console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); + + // 🔍 중복 제거 설정 디버깅 + console.log("🔍 중복 제거 디버깅:", { + panel: deleteModalPanel, + dataFilter: componentConfig.rightPanel?.dataFilter, + deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, + enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, + }); - const result = await dataApi.deleteRecord(tableName, primaryKey); + let result; + + // 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 + if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) { + const deduplication = componentConfig.rightPanel.dataFilter.deduplication; + const groupByColumn = deduplication.groupByColumn; + + if (groupByColumn && deleteModalItem[groupByColumn]) { + const groupValue = deleteModalItem[groupByColumn]; + console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); + + // groupByColumn 값으로 필터링하여 삭제 + const filterConditions: Record = { + [groupByColumn]: groupValue, + }; + + // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) + if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { + const leftColumn = componentConfig.rightPanel.join.leftColumn; + const rightColumn = componentConfig.rightPanel.join.rightColumn; + filterConditions[rightColumn] = selectedLeftItem[leftColumn]; + } + + console.log("🗑️ 그룹 삭제 조건:", filterConditions); + + // 그룹 삭제 API 호출 + result = await dataApi.deleteGroupRecords(tableName, filterConditions); + } else { + // 단일 레코드 삭제 + result = await dataApi.deleteRecord(tableName, primaryKey); + } + } else { + // 단일 레코드 삭제 + result = await dataApi.deleteRecord(tableName, primaryKey); + } if (result.success) { toast({ -- 2.43.0 From 348c040e200b5bfc05032e0498bcaf24511770b1 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 12:19:27 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20SplitPanelLayout=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=EC=BD=94=EB=94=A9=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 하드코딩 제거: 필드명 패턴을 동적으로 처리 - 민감한 필드(id, password, token, company_code)만 제외하고 모두 표시 - 그룹 삭제 기능: 중복 제거 활성화 시 관련된 모든 레코드 삭제 - URL 파라미터 초기화: 모달 닫을 때 자동으로 초기화 - 백엔드: deleteGroupRecords API 추가 - 프론트엔드: dataApi.deleteGroupRecords 클라이언트 추가 --- .../SplitPanelLayoutComponent.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 532ed3a5..21a5bb0f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -60,6 +60,18 @@ export const SplitPanelLayoutComponent: React.FC const resizable = componentConfig.resizable ?? true; const minLeftWidth = componentConfig.minLeftWidth || 200; const minRightWidth = componentConfig.minRightWidth || 300; + + // 필드 표시 유틸리티 (하드코딩 제거, 동적으로 작동) + const shouldShowField = (fieldName: string): boolean => { + const lower = fieldName.toLowerCase(); + + // 기본 제외: id, 비밀번호, 토큰, 회사코드 + if (lower === "id" || lower === "company_code" || lower === "company_name") return false; + if (lower.includes("password") || lower.includes("token")) return false; + + // 나머지는 모두 표시! + return true; + }; // TableOptions Context const { registerTable, unregisterTable } = useTableOptions(); @@ -1690,13 +1702,13 @@ export const SplitPanelLayoutComponent: React.FC value: item[leftColumn], }); - // 추가로 다른 의미있는 필드 1-2개 표시 (name, title 등) + // 추가로 다른 의미있는 필드 1-2개 표시 (동적) const additionalKeys = Object.keys(item).filter( (k) => k !== "id" && k !== "ID" && k !== leftColumn && - (k.includes("name") || k.includes("title") || k.includes("desc")), + shouldShowField(k), ); if (additionalKeys.length > 0) { @@ -1925,7 +1937,7 @@ export const SplitPanelLayoutComponent: React.FC label: rightColumnLabels[col.name] || col.label || col.name, })) : Object.keys(filteredData[0] || {}) - .filter((key) => !key.toLowerCase().includes("password")) + .filter((key) => shouldShowField(key)) .slice(0, 5) .map((key) => ({ name: key, @@ -2537,9 +2549,9 @@ export const SplitPanelLayoutComponent: React.FC
)); } else { - // 설정이 없으면 모든 컬럼 표시 (company_code, company_name 제외) + // 설정이 없으면 모든 컬럼 표시 (민감한 필드 제외) return Object.entries(editModalFormData) - .filter(([key]) => key !== "company_code" && key !== "company_name") + .filter(([key]) => shouldShowField(key)) .map(([key, value]) => (