diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 65dbf84c..90cc07e7 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -120,10 +120,17 @@ export const ScreenModal: React.FC = ({ className }) => { }; }; + // 모달이 열린 시간 추적 (저장 성공 이벤트 무시용) + const modalOpenedAtRef = React.useRef(0); + // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenModal = (event: CustomEvent) => { - const { screenId, title, description, size, urlParams } = event.detail; + const { screenId, title, description, size, urlParams, editData } = event.detail; + + // 🆕 모달 열린 시간 기록 + modalOpenedAtRef.current = Date.now(); + console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current); // 🆕 URL 파라미터가 있으면 현재 URL에 추가 if (urlParams && typeof window !== "undefined") { @@ -136,6 +143,12 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("✅ URL 파라미터 추가:", urlParams); } + // 🆕 editData가 있으면 formData로 설정 (수정 모드) + if (editData) { + console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); + setFormData(editData); + } + setModalState({ isOpen: true, screenId, @@ -171,6 +184,13 @@ export const ScreenModal: React.FC = ({ className }) => { // 저장 성공 이벤트 처리 (연속 등록 모드 지원) const handleSaveSuccess = () => { + // 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지) + const timeSinceOpen = Date.now() - modalOpenedAtRef.current; + if (timeSinceOpen < 500) { + console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen }); + return; + } + const isContinuousMode = continuousMode; console.log("💾 저장 성공 이벤트 수신"); console.log("📌 현재 연속 모드 상태:", isContinuousMode); @@ -581,6 +601,15 @@ export const ScreenModal: React.FC = ({ className }) => { }, }; + // 🆕 formData 전달 확인 로그 + console.log("📝 [ScreenModal] InteractiveScreenViewerDynamic에 formData 전달:", { + componentId: component.id, + componentType: component.type, + componentComponentType: (component as any).componentType, // 🆕 실제 componentType 확인 + hasFormData: !!formData, + formDataKeys: formData ? Object.keys(formData) : [], + }); + return ( void; position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right) + initialFormData?: Record; // 🆕 수정 모드에서 전달되는 초기 데이터 } /** * 임베드된 화면 컴포넌트 */ export const EmbeddedScreen = forwardRef( - ({ embedding, onSelectionChanged, position }, ref) => { + ({ embedding, onSelectionChanged, position, initialFormData }, ref) => { const [layout, setLayout] = useState([]); const [selectedRows, setSelectedRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [screenInfo, setScreenInfo] = useState(null); - const [formData, setFormData] = useState>({}); // 폼 데이터 상태 추가 + const [formData, setFormData] = useState>(initialFormData || {}); // 🆕 초기 데이터로 시작 // 컴포넌트 참조 맵 const componentRefs = useRef>(new Map()); // 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용) const splitPanelContext = useSplitPanelContext(); + + // 🆕 사용자 정보 가져오기 (저장 액션에 필요) + const { userId, userName, companyCode } = useAuth(); + + // 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해) + const contentBounds = React.useMemo(() => { + if (layout.length === 0) return { width: 0, height: 0 }; + + let maxRight = 0; + let maxBottom = 0; + + layout.forEach((component) => { + const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component; + const right = (compPosition.x || 0) + (size.width || 200); + const bottom = (compPosition.y || 0) + (size.height || 40); + + if (right > maxRight) maxRight = right; + if (bottom > maxBottom) maxBottom = bottom; + }); + + return { width: maxRight, height: maxBottom }; + }, [layout]); // 필드 값 변경 핸들러 const handleFieldChange = useCallback((fieldName: string, value: any) => { @@ -59,6 +83,14 @@ export const EmbeddedScreen = forwardRef { + if (initialFormData && Object.keys(initialFormData).length > 0) { + console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData); + setFormData(initialFormData); + } + }, [initialFormData]); + // 선택 변경 이벤트 전파 useEffect(() => { onSelectionChanged?.(selectedRows); @@ -72,10 +104,21 @@ export const EmbeddedScreen = forwardRef -
+
{layout.length === 0 ? (

화면에 컴포넌트가 없습니다.

) : ( -
+
{layout.map((component) => { const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; + // 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정 + // 부모 컨테이너의 100%를 기준으로 계산 + const componentStyle: React.CSSProperties = { + left: compPosition.x || 0, + top: compPosition.y || 0, + width: size.width || 200, + height: size.height || 40, + zIndex: compPosition.z || 1, + // 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정 + maxWidth: `calc(100% - ${compPosition.x || 0}px)`, + }; + return (
); diff --git a/frontend/components/screen-embedding/ScreenSplitPanel.tsx b/frontend/components/screen-embedding/ScreenSplitPanel.tsx index 88901191..2e43fcc6 100644 --- a/frontend/components/screen-embedding/ScreenSplitPanel.tsx +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -16,13 +16,14 @@ import { SplitPanelProvider } from "@/contexts/SplitPanelContext"; interface ScreenSplitPanelProps { screenId?: number; config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable) + initialFormData?: Record; // 🆕 수정 모드에서 전달되는 초기 데이터 } /** * 분할 패널 컴포넌트 * 순수하게 화면 분할 기능만 제공합니다. */ -export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) { +export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) { // config에서 splitRatio 추출 (기본값 50) const configSplitRatio = config?.splitRatio ?? 50; @@ -35,6 +36,13 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) { configKeys: config ? Object.keys(config) : [], }); + // 🆕 initialFormData 별도 로그 (명확한 확인) + console.log("📝 [ScreenSplitPanel] initialFormData 확인:", { + hasInitialFormData: !!initialFormData, + initialFormDataKeys: initialFormData ? Object.keys(initialFormData) : [], + initialFormData: initialFormData, + }); + // 드래그로 조절 가능한 splitRatio 상태 const [splitRatio, setSplitRatio] = useState(configSplitRatio); @@ -122,7 +130,7 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) { {/* 좌측 패널 */}
{hasLeftScreen ? ( - + ) : (

좌측 화면을 선택하세요

@@ -162,7 +170,7 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) { {/* 우측 패널 */}
{hasRightScreen ? ( - + ) : (

우측 화면을 선택하세요

diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index ca6de2d0..ade700e1 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -10,7 +10,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; -import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater"; +import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater"; import { cn } from "@/lib/utils"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; @@ -78,6 +78,12 @@ export const RepeaterInput: React.FC = ({ // 접힌 상태 관리 (각 항목별) const [collapsedItems, setCollapsedItems] = useState>(new Set()); + + // 🆕 초기 계산 완료 여부 추적 (무한 루프 방지) + const initialCalcDoneRef = useRef(false); + + // 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영) + const deletedItemIdsRef = useRef([]); // 빈 항목 생성 function createEmptyItem(): RepeaterItemData { @@ -88,10 +94,39 @@ export const RepeaterInput: React.FC = ({ return item; } - // 외부 value 변경 시 동기화 + // 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트 useEffect(() => { if (value.length > 0) { - setItems(value); + // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) + const calculatedFields = fields.filter(f => f.type === "calculated"); + + if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { + const updatedValue = value.map(item => { + const updatedItem = { ...item }; + let hasChange = false; + + calculatedFields.forEach(calcField => { + const calculatedValue = calculateValue(calcField.formula, updatedItem); + if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) { + updatedItem[calcField.name] = calculatedValue; + hasChange = true; + } + }); + + return hasChange ? updatedItem : item; + }); + + setItems(updatedValue); + initialCalcDoneRef.current = true; + + // 계산된 값이 있으면 onChange 호출 (초기 1회만) + const dataWithMeta = config.targetTable + ? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable })) + : updatedValue; + onChange?.(dataWithMeta); + } else { + setItems(value); + } } }, [value]); @@ -117,14 +152,32 @@ export const RepeaterInput: React.FC = ({ if (items.length <= minItems) { return; } + + // 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요) + const removedItem = items[index]; + if (removedItem?.id) { + console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id); + deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id]; + } + const newItems = items.filter((_, i) => i !== index); setItems(newItems); // targetTable이 설정된 경우 각 항목에 메타데이터 추가 + // 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용) + const currentDeletedIds = deletedItemIdsRef.current; + console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds); + const dataWithMeta = config.targetTable - ? newItems.map((item) => ({ ...item, _targetTable: config.targetTable })) + ? newItems.map((item, idx) => ({ + ...item, + _targetTable: config.targetTable, + // 첫 번째 항목에만 삭제 ID 목록 포함 + ...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}), + })) : newItems; + console.log("🗑️ [RepeaterInput] onChange 호출 - dataWithMeta:", dataWithMeta); onChange?.(dataWithMeta); // 접힌 상태도 업데이트 @@ -140,6 +193,16 @@ export const RepeaterInput: React.FC = ({ ...newItems[itemIndex], [fieldName]: value, }; + + // 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산 + const calculatedFields = fields.filter(f => f.type === "calculated"); + calculatedFields.forEach(calcField => { + const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]); + if (calculatedValue !== null) { + newItems[itemIndex][calcField.name] = calculatedValue; + } + }); + setItems(newItems); console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", { itemIndex, @@ -149,8 +212,15 @@ export const RepeaterInput: React.FC = ({ }); // targetTable이 설정된 경우 각 항목에 메타데이터 추가 + // 🆕 삭제된 항목 ID 목록도 유지 + const currentDeletedIds = deletedItemIdsRef.current; const dataWithMeta = config.targetTable - ? newItems.map((item) => ({ ...item, _targetTable: config.targetTable })) + ? newItems.map((item, idx) => ({ + ...item, + _targetTable: config.targetTable, + // 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만) + ...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}), + })) : newItems; onChange?.(dataWithMeta); @@ -198,6 +268,95 @@ export const RepeaterInput: React.FC = ({ setDraggedIndex(null); }; + /** + * 계산식 실행 + * @param formula 계산식 정의 + * @param item 현재 항목 데이터 + * @returns 계산 결과 + */ + const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => { + if (!formula || !formula.field1) return null; + + const value1 = parseFloat(item[formula.field1]) || 0; + const value2 = formula.field2 + ? (parseFloat(item[formula.field2]) || 0) + : (formula.constantValue ?? 0); + + let result: number; + + switch (formula.operator) { + case "+": + result = value1 + value2; + break; + case "-": + result = value1 - value2; + break; + case "*": + result = value1 * value2; + break; + case "/": + result = value2 !== 0 ? value1 / value2 : 0; + break; + case "%": + result = value2 !== 0 ? value1 % value2 : 0; + break; + case "round": + const decimalPlaces = formula.decimalPlaces ?? 0; + const multiplier = Math.pow(10, decimalPlaces); + result = Math.round(value1 * multiplier) / multiplier; + break; + case "floor": + const floorMultiplier = Math.pow(10, formula.decimalPlaces ?? 0); + result = Math.floor(value1 * floorMultiplier) / floorMultiplier; + break; + case "ceil": + const ceilMultiplier = Math.pow(10, formula.decimalPlaces ?? 0); + result = Math.ceil(value1 * ceilMultiplier) / ceilMultiplier; + break; + case "abs": + result = Math.abs(value1); + break; + default: + result = value1; + } + + return result; + }; + + /** + * 숫자 포맷팅 + * @param value 숫자 값 + * @param format 포맷 설정 + * @returns 포맷된 문자열 + */ + const formatNumber = ( + value: number | null, + format?: RepeaterFieldDefinition["numberFormat"] + ): string => { + if (value === null || isNaN(value)) return "-"; + + let formattedValue = value; + + // 소수점 자릿수 적용 + if (format?.decimalPlaces !== undefined) { + formattedValue = parseFloat(value.toFixed(format.decimalPlaces)); + } + + // 천 단위 구분자 + let result = format?.useThousandSeparator !== false + ? formattedValue.toLocaleString("ko-KR", { + minimumFractionDigits: format?.minimumFractionDigits ?? 0, + maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0, + }) + : formattedValue.toString(); + + // 접두사/접미사 추가 + if (format?.prefix) result = format.prefix + result; + if (format?.suffix) result = result + format.suffix; + + return result; + }; + // 개별 필드 렌더링 const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { const isReadonly = disabled || readonly || field.readonly; @@ -209,6 +368,19 @@ export const RepeaterInput: React.FC = ({ required: field.required, }; + // 계산식 필드: 자동으로 계산된 값을 표시 (읽기 전용) + if (field.type === "calculated") { + const item = items[itemIndex]; + const calculatedValue = calculateValue(field.formula, item); + const formattedValue = formatNumber(calculatedValue, field.numberFormat); + + return ( + + {formattedValue} + + ); + } + // 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용) if (field.type === "category") { if (!value) return -; @@ -272,7 +444,7 @@ export const RepeaterInput: React.FC = ({ onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)} disabled={isReadonly} > - + @@ -291,7 +463,7 @@ export const RepeaterInput: React.FC = ({ {...commonProps} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} rows={3} - className="resize-none" + className="resize-none min-w-[100px]" /> ); @@ -301,10 +473,45 @@ export const RepeaterInput: React.FC = ({ {...commonProps} type="date" onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} + className="min-w-[120px]" /> ); case "number": + // 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시 + if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) { + const numValue = parseFloat(value) || 0; + const formattedDisplay = formatNumber(numValue, field.numberFormat); + + // 읽기 전용이면 포맷팅된 텍스트만 표시 + if (isReadonly) { + return ( + + {formattedDisplay} + + ); + } + + // 편집 가능: 입력은 숫자로, 표시는 포맷팅 + return ( +
+ handleFieldChange(itemIndex, field.name, e.target.value)} + min={field.validation?.min} + max={field.validation?.max} + className="pr-1" + /> + {value && ( +
+ {formattedDisplay} +
+ )} +
+ ); + } + return ( = ({ onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} min={field.validation?.min} max={field.validation?.max} + className="min-w-[80px]" /> ); @@ -321,6 +529,7 @@ export const RepeaterInput: React.FC = ({ {...commonProps} type="email" onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} + className="min-w-[120px]" /> ); @@ -330,6 +539,7 @@ export const RepeaterInput: React.FC = ({ {...commonProps} type="tel" onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} + className="min-w-[100px]" /> ); @@ -340,6 +550,7 @@ export const RepeaterInput: React.FC = ({ type="text" onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} maxLength={field.validation?.maxLength} + className="min-w-[80px]" /> ); } @@ -444,18 +655,18 @@ export const RepeaterInput: React.FC = ({ {showIndex && ( - # + # )} {allowReorder && ( - + )} {fields.map((field) => ( - + {field.label} {field.required && *} ))} - 작업 + 작업 @@ -474,27 +685,27 @@ export const RepeaterInput: React.FC = ({ > {/* 인덱스 번호 */} {showIndex && ( - + {itemIndex + 1} )} {/* 드래그 핸들 */} {allowReorder && !readonly && !disabled && ( - + )} {/* 필드들 */} {fields.map((field) => ( - + {renderField(field, itemIndex, item[field.name])} ))} {/* 삭제 버튼 */} - + {!readonly && !disabled && items.length > minItems && (
+ {/* 그룹화 컬럼 설정 */} +
+ + +

+ 수정 모드에서 이 컬럼 값을 기준으로 관련된 모든 데이터를 조회합니다. +
+ 예: 입고번호를 선택하면 같은 입고번호를 가진 모든 품목이 표시됩니다. +

+
+ {/* 필드 정의 */}
@@ -319,6 +345,12 @@ export const RepeaterConfigPanel: React.FC = ({ 공통코드 (code) 이미지 (image) 직접입력 (direct) + + + + 계산식 (calculated) + +
@@ -335,6 +367,253 @@ export const RepeaterConfigPanel: React.FC = ({
+ {/* 계산식 타입일 때 계산식 설정 */} + {field.type === "calculated" && ( +
+
+ + +
+ + {/* 필드 1 선택 */} +
+ + +
+ + {/* 연산자 선택 */} +
+ + +
+ + {/* 두 번째 필드 또는 상수값 */} + {!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? ( +
+ + +
+ ) : ( +
+ + updateField(index, { + formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula + })} + className="h-8 text-xs" + /> +
+ )} + + {/* 상수값 입력 필드 */} + {field.formula?.constantValue !== undefined && ( +
+ + updateField(index, { + formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula + })} + placeholder="숫자 입력" + className="h-8 text-xs" + /> +
+ )} + + {/* 숫자 포맷 설정 */} +
+ +
+
+ updateField(index, { + numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean } + })} + /> + +
+
+ + updateField(index, { + numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 } + })} + type="number" + min={0} + max={10} + className="h-6 w-12 text-[10px]" + /> +
+
+
+ updateField(index, { + numberFormat: { ...field.numberFormat, prefix: e.target.value } + })} + placeholder="접두사 (₩)" + className="h-7 text-[10px]" + /> + updateField(index, { + numberFormat: { ...field.numberFormat, suffix: e.target.value } + })} + placeholder="접미사 (원)" + className="h-7 text-[10px]" + /> +
+
+ + {/* 계산식 미리보기 */} +
+ 계산식: + + {field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} { + field.formula?.field2 || + (field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2") + } + +
+
+ )} + + {/* 숫자 타입일 때 숫자 표시 형식 설정 */} + {field.type === "number" && ( +
+ +
+
+ updateField(index, { + numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean } + })} + /> + +
+
+ + updateField(index, { + numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 } + })} + type="number" + min={0} + max={10} + className="h-6 w-12 text-[10px]" + /> +
+
+
+ updateField(index, { + numberFormat: { ...field.numberFormat, prefix: e.target.value } + })} + placeholder="접두사 (₩)" + className="h-7 text-[10px]" + /> + updateField(index, { + numberFormat: { ...field.numberFormat, suffix: e.target.value } + })} + placeholder="접미사 (원)" + className="h-7 text-[10px]" + /> +
+
+ )} + {/* 카테고리 타입일 때 카테고리 코드 입력 */} {field.type === "category" && (
diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index b6e34588..fe93f4af 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -224,6 +224,19 @@ export const DynamicComponentRenderer: React.FC = // 1. 새 컴포넌트 시스템에서 먼저 조회 const newComponent = ComponentRegistry.getComponent(componentType); + // 🔍 디버깅: screen-split-panel 조회 결과 확인 + if (componentType === "screen-split-panel") { + console.log("🔍 [DynamicComponentRenderer] screen-split-panel 조회:", { + componentType, + found: !!newComponent, + componentId: component.id, + componentConfig: component.componentConfig, + hasFormData: !!props.formData, + formDataKeys: props.formData ? Object.keys(props.formData) : [], + registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id), + }); + } + // 🔍 디버깅: select-basic 조회 결과 확인 if (componentType === "select-basic") { console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", { @@ -308,6 +321,19 @@ export const DynamicComponentRenderer: React.FC = } else { currentValue = formData?.[fieldName] || ""; } + + // 🆕 디버깅: text-input 값 추출 확인 + if (componentType === "text-input" && formData && Object.keys(formData).length > 0) { + console.log("🔍 [DynamicComponentRenderer] text-input 값 추출:", { + componentId: component.id, + componentLabel: component.label, + columnName: (component as any).columnName, + fieldName, + currentValue, + hasFormData: !!formData, + formDataKeys: Object.keys(formData).slice(0, 10), // 처음 10개만 + }); + } // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 const handleChange = (value: any) => { diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 67b253e1..b42ec9b8 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -105,6 +105,10 @@ export const ButtonPrimaryComponent: React.FC = ({ // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) const splitPanelPosition = screenContext?.splitPanelPosition; + // 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기 + const effectiveTableName = tableName || screenContext?.tableName; + const effectiveScreenId = screenId || screenContext?.screenId; + // 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출) const propsOnSave = (props as any).onSave as (() => Promise) | undefined; const finalOnSave = onSave || propsOnSave; @@ -677,11 +681,21 @@ export const ButtonPrimaryComponent: React.FC = ({ } } + // 🆕 디버깅: tableName 확인 + console.log("🔍 [ButtonPrimaryComponent] context 생성:", { + propsTableName: tableName, + contextTableName: screenContext?.tableName, + effectiveTableName, + propsScreenId: screenId, + contextScreenId: screenContext?.screenId, + effectiveScreenId, + }); + const context: ButtonActionContext = { formData: formData || {}, originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 - screenId, - tableName, + screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용 + tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용 userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 companyCode, // 🆕 회사 코드 diff --git a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx index 52853746..a4dbd157 100644 --- a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx +++ b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useRef, useCallback, useMemo } from "react"; +import React, { useEffect, useRef, useCallback, useMemo, useState } from "react"; import { Layers } from "lucide-react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component"; @@ -10,6 +10,7 @@ import { useScreenContextOptional, DataReceivable } from "@/contexts/ScreenConte import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { applyMappingRules } from "@/lib/utils/dataMapping"; import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; /** * Repeater Field Group 컴포넌트 @@ -19,27 +20,149 @@ const RepeaterFieldGroupComponent: React.FC = (props) => const screenContext = useScreenContextOptional(); const splitPanelContext = useSplitPanelContext(); const receiverRef = useRef(null); + + // 🆕 그룹화된 데이터를 저장하는 상태 + const [groupedData, setGroupedData] = useState(null); + const [isLoadingGroupData, setIsLoadingGroupData] = useState(false); + const groupDataLoadedRef = useRef(false); + + // 🆕 원본 데이터 ID 목록 (삭제 추적용) + const [originalItemIds, setOriginalItemIds] = useState([]); // 컴포넌트의 필드명 (formData 키) const fieldName = (component as any).columnName || component.id; // repeaterConfig 또는 componentConfig에서 설정 가져오기 const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] }; + + // 🆕 그룹화 설정 (예: groupByColumn: "inbound_number") + const groupByColumn = config.groupByColumn; + const targetTable = config.targetTable; // formData에서 값 가져오기 (value prop보다 우선) const rawValue = formData?.[fieldName] ?? value; + // 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우 + // formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시) + const isEditMode = formData?.id && !rawValue && !value; + + // 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인 + const configFields = config.fields || []; + const hasRepeaterFieldsInFormData = configFields.length > 0 && + configFields.some((field: any) => formData?.[field.name] !== undefined); + + // 🆕 formData와 config.fields의 필드 이름 매칭 확인 + const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined); + + // 🆕 그룹 키 값 (예: formData.inbound_number) + const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null; + console.log("🔄 [RepeaterFieldGroup] 렌더링:", { fieldName, hasFormData: !!formData, + formDataId: formData?.id, formDataValue: formData?.[fieldName], propsValue: value, rawValue, + isEditMode, + hasRepeaterFieldsInFormData, + configFieldNames: configFields.map((f: any) => f.name), + formDataKeys: formData ? Object.keys(formData) : [], + matchingFieldNames: matchingFields.map((f: any) => f.name), + groupByColumn, + groupKeyValue, + targetTable, + hasGroupedData: groupedData !== null, + groupedDataLength: groupedData?.length, }); + // 🆕 수정 모드에서 그룹화된 데이터 로드 + useEffect(() => { + const loadGroupedData = async () => { + // 이미 로드했거나 조건이 맞지 않으면 스킵 + if (groupDataLoadedRef.current) return; + if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return; + + console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", { + groupByColumn, + groupKeyValue, + targetTable, + }); + + setIsLoadingGroupData(true); + groupDataLoadedRef.current = true; + + try { + // API 호출: 같은 그룹 키를 가진 모든 데이터 조회 + // search 파라미터 사용 (filters가 아닌 search) + const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, { + page: 1, + size: 100, // 충분히 큰 값 + search: { [groupByColumn]: groupKeyValue }, + }); + + console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", { + success: response.data?.success, + hasData: !!response.data?.data, + dataType: typeof response.data?.data, + dataKeys: response.data?.data ? Object.keys(response.data.data) : [], + }); + + // 응답 구조: { success, data: { data: [...], total, page, totalPages } } + if (response.data?.success && response.data?.data?.data) { + const items = response.data.data.data; // 실제 데이터 배열 + console.log("✅ [RepeaterFieldGroup] 그룹 데이터 로드 완료:", { + count: items.length, + groupByColumn, + groupKeyValue, + firstItem: items[0], + }); + setGroupedData(items); + + // 🆕 원본 데이터 ID 목록 저장 (삭제 추적용) + const itemIds = items.map((item: any) => item.id).filter(Boolean); + setOriginalItemIds(itemIds); + console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds); + + // onChange 호출하여 부모에게 알림 + if (onChange && items.length > 0) { + const dataWithMeta = items.map((item: any) => ({ + ...item, + _targetTable: targetTable, + _originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달 + })); + onChange(dataWithMeta); + } + } else { + console.warn("⚠️ [RepeaterFieldGroup] 그룹 데이터 로드 실패:", response.data); + setGroupedData([]); + } + } catch (error) { + console.error("❌ [RepeaterFieldGroup] 그룹 데이터 로드 오류:", error); + setGroupedData([]); + } finally { + setIsLoadingGroupData(false); + } + }; + + loadGroupedData(); + }, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]); + // 값이 JSON 문자열인 경우 파싱 let parsedValue: any[] = []; - if (typeof rawValue === "string") { + + // 🆕 그룹화된 데이터가 있으면 우선 사용 + if (groupedData !== null && groupedData.length > 0) { + parsedValue = groupedData; + } else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) { + // 그룹화 설정이 없는 경우에만 단일 행 사용 + console.log("📝 [RepeaterFieldGroup] 수정 모드 - formData를 초기 데이터로 사용", { + formDataId: formData?.id, + matchingFieldsCount: matchingFields.length, + }); + parsedValue = [{ ...formData }]; + } else if (typeof rawValue === "string" && rawValue.trim() !== "") { + // 빈 문자열이 아닌 경우에만 JSON 파싱 시도 try { parsedValue = JSON.parse(rawValue); } catch { @@ -65,6 +188,10 @@ const RepeaterFieldGroupComponent: React.FC = (props) => const fieldNameRef = useRef(fieldName); fieldNameRef.current = fieldName; + // config를 ref로 관리 + const configRef = useRef(config); + configRef.current = config; + // 데이터 수신 핸들러 const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => { console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode }); @@ -92,14 +219,34 @@ const RepeaterFieldGroupComponent: React.FC = (props) => return item; }); + // 🆕 정의된 필드만 필터링 (불필요한 필드 제거) + // 반복 필드 그룹에 정의된 필드 + 시스템 필드만 유지 + const definedFields = configRef.current.fields || []; + const definedFieldNames = new Set(definedFields.map((f: any) => f.name)); + // 시스템 필드 및 필수 필드 추가 + const systemFields = new Set(['id', '_targetTable', 'created_date', 'updated_date', 'writer', 'company_code']); + + const filteredData = normalizedData.map((item: any) => { + const filteredItem: Record = {}; + Object.keys(item).forEach(key => { + // 정의된 필드이거나 시스템 필드인 경우만 포함 + if (definedFieldNames.has(key) || systemFields.has(key)) { + filteredItem[key] = item[key]; + } + }); + return filteredItem; + }); + console.log("📥 [RepeaterFieldGroup] 정규화된 데이터:", normalizedData); + console.log("📥 [RepeaterFieldGroup] 필터링된 데이터:", filteredData); // 기존 데이터에 새 데이터 추가 (기본 모드: append) const currentValue = parsedValueRef.current; // mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가 + // 🆕 필터링된 데이터 사용 const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append"; - const newItems = mode === "replace" ? normalizedData : [...currentValue, ...normalizedData]; + const newItems = mode === "replace" ? filteredData : [...currentValue, ...filteredData]; console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode }); @@ -121,7 +268,7 @@ const RepeaterFieldGroupComponent: React.FC = (props) => onChangeRef.current(jsonValue); } - toast.success(`${normalizedData.length}개 항목이 추가되었습니다`); + toast.success(`${filteredData.length}개 항목이 추가되었습니다`); }, []); // DataReceivable 인터페이스 구현 diff --git a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx index 4397dc29..0b9cd148 100644 --- a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx +++ b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx @@ -19,7 +19,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer { description: "좌우 화면 임베딩 및 데이터 전달 기능을 제공하는 분할 패널", category: ComponentCategory.LAYOUT, webType: "text", // 레이아웃 컴포넌트는 기본 webType 사용 - component: ScreenSplitPanel, // React 컴포넌트 + component: ScreenSplitPanelRenderer, // 🆕 Renderer 클래스 자체를 등록 (ScreenSplitPanel 아님) configPanel: ScreenSplitPanelConfigPanel, // 설정 패널 tags: ["split", "panel", "embed", "data-transfer", "layout"], defaultSize: { @@ -68,7 +68,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer { render() { console.log("🚀 [ScreenSplitPanelRenderer] render() 호출됨!", this.props); - const { component, style = {}, componentConfig, config, screenId } = this.props as any; + const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any; // componentConfig 또는 config 또는 component.componentConfig 사용 const finalConfig = componentConfig || config || component?.componentConfig || {}; @@ -78,16 +78,27 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer { hasConfig: !!config, hasComponentComponentConfig: !!component?.componentConfig, finalConfig, - splitRatio: finalConfig.splitRatio, // 🆕 splitRatio 확인 + splitRatio: finalConfig.splitRatio, leftScreenId: finalConfig.leftScreenId, rightScreenId: finalConfig.rightScreenId, componentType: component?.componentType, componentId: component?.id, }); + + // 🆕 formData 별도 로그 (명확한 확인) + console.log("📝 [ScreenSplitPanelRenderer] formData 확인:", { + hasFormData: !!formData, + formDataKeys: formData ? Object.keys(formData) : [], + formData: formData, + }); return (
- +
); } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index d6a4b676..b0d843ab 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -471,6 +471,66 @@ export class ButtonActionExecutor { } } + // 🆕 반복 필드 그룹에서 삭제된 항목 처리 + // formData의 각 필드에서 _deletedItemIds가 있는지 확인 + console.log("🔍 [handleSave] 삭제 항목 검색 시작 - dataWithUserInfo 키:", Object.keys(dataWithUserInfo)); + + for (const [key, value] of Object.entries(dataWithUserInfo)) { + console.log(`🔍 [handleSave] 필드 검사: ${key}`, { + type: typeof value, + isArray: Array.isArray(value), + isString: typeof value === "string", + valuePreview: typeof value === "string" ? value.substring(0, 100) : value, + }); + + let parsedValue = value; + + // JSON 문자열인 경우 파싱 시도 + if (typeof value === "string" && value.startsWith("[")) { + try { + parsedValue = JSON.parse(value); + console.log(`🔍 [handleSave] JSON 파싱 성공: ${key}`, parsedValue); + } catch (e) { + // 파싱 실패하면 원본 값 유지 + } + } + + if (Array.isArray(parsedValue) && parsedValue.length > 0) { + const firstItem = parsedValue[0]; + const deletedItemIds = firstItem?._deletedItemIds; + const targetTable = firstItem?._targetTable; + + console.log(`🔍 [handleSave] 배열 필드 분석: ${key}`, { + firstItemKeys: firstItem ? Object.keys(firstItem) : [], + deletedItemIds, + targetTable, + }); + + if (deletedItemIds && deletedItemIds.length > 0 && targetTable) { + console.log("🗑️ [handleSave] 삭제할 항목 발견:", { + fieldKey: key, + targetTable, + deletedItemIds, + }); + + // 삭제 API 호출 + for (const itemId of deletedItemIds) { + try { + console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`); + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable); + if (deleteResult.success) { + console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`); + } else { + console.warn(`⚠️ [handleSave] 항목 삭제 실패: ${itemId}`, deleteResult.message); + } + } catch (deleteError) { + console.error(`❌ [handleSave] 항목 삭제 오류: ${itemId}`, deleteError); + } + } + } + } + } + saveResult = await DynamicFormApi.saveFormData({ screenId, tableName, @@ -1398,16 +1458,59 @@ export class ButtonActionExecutor { let description = config.editModalDescription || ""; // 2. config에 없으면 화면 정보에서 가져오기 - if (!description && config.targetScreenId) { + let screenInfo: any = null; + if (config.targetScreenId) { try { - const screenInfo = await screenApi.getScreen(config.targetScreenId); - description = screenInfo?.description || ""; + screenInfo = await screenApi.getScreen(config.targetScreenId); + if (!description) { + description = screenInfo?.description || ""; + } } catch (error) { console.warn("화면 설명을 가져오지 못했습니다:", error); } } - // 🔧 항상 EditModal 사용 (groupByColumns는 EditModal에서 처리) + // 🆕 화면이 분할 패널을 포함하는지 확인 (레이아웃에 screen-split-panel 컴포넌트가 있는지) + let hasSplitPanel = false; + if (config.targetScreenId) { + try { + const layoutData = await screenApi.getLayout(config.targetScreenId); + if (layoutData?.components) { + hasSplitPanel = layoutData.components.some( + (comp: any) => + comp.type === "screen-split-panel" || + comp.componentType === "screen-split-panel" || + comp.type === "split-panel-layout" || + comp.componentType === "split-panel-layout" + ); + } + console.log("🔍 [openEditModal] 분할 패널 확인:", { + targetScreenId: config.targetScreenId, + hasSplitPanel, + componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [], + }); + } catch (error) { + console.warn("레이아웃 정보를 가져오지 못했습니다:", error); + } + } + + // 🆕 분할 패널 화면인 경우 ScreenModal 사용 (editData 전달) + if (hasSplitPanel) { + console.log("📋 [openEditModal] 분할 패널 화면 - ScreenModal 사용"); + const screenModalEvent = new CustomEvent("openScreenModal", { + detail: { + screenId: config.targetScreenId, + title: config.editModalTitle || "데이터 수정", + description: description, + size: config.modalSize || "lg", + editData: rowData, // 🆕 수정 데이터 전달 + }, + }); + window.dispatchEvent(screenModalEvent); + return; + } + + // 🔧 일반 화면은 EditModal 사용 (groupByColumns는 EditModal에서 처리) const modalEvent = new CustomEvent("openEditModal", { detail: { screenId: config.targetScreenId, diff --git a/frontend/types/repeater.ts b/frontend/types/repeater.ts index 00e06b7f..c095143f 100644 --- a/frontend/types/repeater.ts +++ b/frontend/types/repeater.ts @@ -18,8 +18,27 @@ export type RepeaterFieldType = | "code" // 공통코드 | "image" // 이미지 | "direct" // 직접입력 + | "calculated" // 계산식 필드 | string; // 기타 커스텀 타입 허용 +/** + * 계산식 연산자 + */ +export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor" | "ceil" | "abs"; + +/** + * 계산식 정의 + * 예: { field1: "order_qty", operator: "*", field2: "unit_price" } → order_qty * unit_price + * 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2) + */ +export interface CalculationFormula { + field1: string; // 첫 번째 필드명 + operator: CalculationOperator; // 연산자 + field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요) + constantValue?: number; // 상수값 (field2 대신 사용 가능) + decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용) +} + /** * 필드 표시 모드 * - input: 입력 필드로 표시 (편집 가능) @@ -42,6 +61,13 @@ export interface RepeaterFieldDefinition { width?: string; // 필드 너비 (예: "200px", "50%") displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용) categoryCode?: string; // category 타입일 때 사용할 카테고리 코드 + formula?: CalculationFormula; // 계산식 (type이 "calculated"일 때 사용) + numberFormat?: { + useThousandSeparator?: boolean; // 천 단위 구분자 사용 + prefix?: string; // 접두사 (예: "₩") + suffix?: string; // 접미사 (예: "원") + decimalPlaces?: number; // 소수점 자릿수 + }; validation?: { minLength?: number; maxLength?: number; @@ -57,6 +83,7 @@ export interface RepeaterFieldDefinition { export interface RepeaterFieldGroupConfig { fields: RepeaterFieldDefinition[]; // 반복될 필드 정의 targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블) + groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number") minItems?: number; // 최소 항목 수 maxItems?: number; // 최대 항목 수 addButtonText?: string; // 추가 버튼 텍스트