ERP-node/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx

842 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
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";
import { RepeaterInput } from "@/components/webtypes/RepeaterInput";
import { RepeaterConfigPanel } from "@/components/webtypes/config/RepeaterConfigPanel";
import { useScreenContextOptional, DataReceivable } from "@/contexts/ScreenContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { applyMappingRules } from "@/lib/utils/dataMapping";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
/**
* Repeater Field Group 컴포넌트
*/
const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) => {
const { component, value, onChange, readonly, disabled, formData, onFormDataChange, menuObjid } = props;
const screenContext = useScreenContextOptional();
const splitPanelContext = useSplitPanelContext();
const receiverRef = useRef<DataReceivable | null>(null);
// 🆕 그룹화된 데이터를 저장하는 상태
const [groupedData, setGroupedData] = useState<any[] | null>(null);
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
const groupDataLoadedRef = useRef(false);
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
// 🆕 DB에서 로드한 컬럼 정보 (webType 등)
const [columnInfo, setColumnInfo] = useState<Record<string, any>>({});
// 컴포넌트의 필드명 (formData 키)
const fieldName = (component as any).columnName || component.id;
// repeaterConfig 또는 componentConfig에서 설정 가져오기
const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
const groupByColumn = rawConfig.groupByColumn;
const targetTable = rawConfig.targetTable;
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
const config = useMemo(() => {
const rawFields = rawConfig.fields || [];
console.log("📋 [RepeaterFieldGroup] config 생성:", {
rawFieldsCount: rawFields.length,
rawFieldNames: rawFields.map((f: any) => f.name),
columnInfoKeys: Object.keys(columnInfo),
hasColumnInfo: Object.keys(columnInfo).length > 0,
});
const fields = rawFields.map((field: any) => {
const colInfo = columnInfo[field.name];
// DB의 webType 또는 web_type을 field.type으로 적용
const dbWebType = colInfo?.webType || colInfo?.web_type;
// 타입 오버라이드 조건:
// 1. field.type이 없거나
// 2. field.type이 'direct'(기본값)이고 DB에 더 구체적인 타입이 있는 경우
const shouldOverride = !field.type || (field.type === "direct" && dbWebType && dbWebType !== "text");
if (colInfo && dbWebType && shouldOverride) {
console.log(`✅ [RepeaterFieldGroup] 필드 타입 매핑: ${field.name}${dbWebType}`);
return { ...field, type: dbWebType };
}
return field;
});
return { ...rawConfig, fields };
}, [rawConfig, columnInfo]);
// 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;
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
const splitPanelPosition = screenContext?.splitPanelPosition;
const isRightPanel = splitPanelPosition === "right";
const selectedLeftData = splitPanelContext?.selectedLeftData;
// 🆕 연결 필터 설정에서 FK 컬럼 정보 가져오기
// screen-split-panel에서 설정한 linkedFilters 사용
const linkedFilters = splitPanelContext?.linkedFilters || [];
const getLinkedFilterValues = splitPanelContext?.getLinkedFilterValues;
// 🆕 FK 컬럼 설정 우선순위:
// 1. linkedFilters에서 targetTable에 해당하는 설정 찾기
// 2. config.fkColumn (컴포넌트 설정)
// 3. config.groupByColumn (그룹화 컬럼)
let fkSourceColumn: string | null = null;
let fkTargetColumn: string | null = null;
let linkedFilterTargetTable: string | null = null;
// linkedFilters에서 FK 컬럼 찾기
if (linkedFilters.length > 0 && selectedLeftData) {
// 첫 번째 linkedFilter 사용 (일반적으로 하나만 설정됨)
const linkedFilter = linkedFilters[0];
fkSourceColumn = linkedFilter.sourceColumn;
// targetColumn이 "테이블명.컬럼명" 형식일 수 있음 → 분리
// 예: "dtg_maintenance_history.serial_no" → table: "dtg_maintenance_history", column: "serial_no"
const targetColumnParts = linkedFilter.targetColumn.split(".");
if (targetColumnParts.length === 2) {
linkedFilterTargetTable = targetColumnParts[0];
fkTargetColumn = targetColumnParts[1];
} else {
fkTargetColumn = linkedFilter.targetColumn;
}
}
// 🆕 targetTable 우선순위: config.targetTable > linkedFilters에서 추출한 테이블
const effectiveTargetTable = targetTable || linkedFilterTargetTable;
// 🆕 DB에서 컬럼 정보 로드 (webType 등)
useEffect(() => {
const loadColumnInfo = async () => {
if (!effectiveTargetTable) return;
try {
const response = await apiClient.get(`/table-management/tables/${effectiveTargetTable}/columns`);
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 응답:", response.data);
// 응답 구조에 따라 데이터 추출
// 실제 응답: { success: true, data: { columns: [...], page, size, total, totalPages } }
let columns: any[] = [];
if (response.data?.success && response.data?.data) {
// data.columns가 배열인 경우 (실제 응답 구조)
if (Array.isArray(response.data.data.columns)) {
columns = response.data.data.columns;
}
// data가 배열인 경우
else if (Array.isArray(response.data.data)) {
columns = response.data.data;
}
// data 자체가 객체이고 배열이 아닌 경우 (키-값 형태)
else if (typeof response.data.data === "object") {
columns = Object.values(response.data.data);
}
}
// success 없이 바로 배열인 경우
else if (Array.isArray(response.data)) {
columns = response.data;
}
console.log("📋 [RepeaterFieldGroup] 파싱된 컬럼 배열:", columns.length, "개");
if (columns.length > 0) {
const colMap: Record<string, any> = {};
columns.forEach((col: any) => {
// columnName 또는 column_name 또는 name 키 사용
const colName = col.columnName || col.column_name || col.name;
if (colName) {
colMap[colName] = col;
}
});
setColumnInfo(colMap);
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 로드 완료:", {
table: effectiveTargetTable,
columns: Object.keys(colMap),
webTypes: Object.entries(colMap).map(
([name, info]: [string, any]) => `${name}: ${info.webType || info.web_type || "unknown"}`,
),
});
}
} catch (error) {
console.error("❌ [RepeaterFieldGroup] 컬럼 정보 로드 실패:", error);
}
};
loadColumnInfo();
}, [effectiveTargetTable]);
// linkedFilters가 없으면 config에서 가져오기
const fkColumn = fkTargetColumn || config.fkColumn || config.groupByColumn;
const fkValue =
fkSourceColumn && selectedLeftData
? selectedLeftData[fkSourceColumn]
: fkColumn && selectedLeftData
? selectedLeftData[fkColumn]
: 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,
linkedFilterTargetTable,
effectiveTargetTable,
hasGroupedData: groupedData !== null,
groupedDataLength: groupedData?.length,
// 🆕 분할 패널 관련 정보
linkedFiltersCount: linkedFilters.length,
linkedFilters: linkedFilters.map((f) => `${f.sourceColumn}${f.targetColumn}`),
fkSourceColumn,
fkTargetColumn,
splitPanelPosition,
isRightPanel,
hasSelectedLeftData: !!selectedLeftData,
// 🆕 selectedLeftData 상세 정보 (디버깅용)
selectedLeftDataId: selectedLeftData?.id,
selectedLeftDataFkValue: fkSourceColumn ? selectedLeftData?.[fkSourceColumn] : "N/A",
selectedLeftData: selectedLeftData ? JSON.stringify(selectedLeftData).slice(0, 200) : null,
fkColumn,
fkValue,
});
// 🆕 수정 모드에서 그룹화된 데이터 로드
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) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
setOriginalItemIds(itemIds);
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
splitPanelContext.addItemIds(itemIds);
}
// onChange 호출하여 부모에게 알림
if (onChange && items.length > 0) {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: targetTable,
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
_existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드)
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
}));
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]);
// 🆕 분할 패널에서 좌측 데이터 선택 시 FK 기반으로 데이터 로드
// 좌측 테이블의 serial_no 등을 기준으로 우측 repeater 데이터 필터링
const prevFkValueRef = useRef<string | null>(null);
useEffect(() => {
const loadDataByFK = async () => {
// 우측 패널이 아니면 스킵
if (!isRightPanel) {
return;
}
// 🆕 fkValue가 없거나 빈 값이면 빈 상태로 초기화
if (!fkValue || fkValue === "" || fkValue === null || fkValue === undefined) {
console.log("🔄 [RepeaterFieldGroup] FK 값 없음 - 빈 상태로 초기화:", {
fkColumn,
fkValue,
prevFkValue: prevFkValueRef.current,
});
// 이전에 데이터가 있었다면 초기화
if (prevFkValueRef.current !== null) {
setGroupedData([]);
setOriginalItemIds([]);
onChange?.([]);
prevFkValueRef.current = null;
}
return;
}
// FK 컬럼이나 타겟 테이블이 없으면 스킵
if (!fkColumn || !effectiveTargetTable) {
console.log("⏭️ [RepeaterFieldGroup] FK 기반 로드 스킵 (설정 부족):", {
fkColumn,
effectiveTargetTable,
});
return;
}
// 같은 FK 값으로 이미 로드했으면 스킵
const currentFkValueStr = String(fkValue);
if (prevFkValueRef.current === currentFkValueStr) {
console.log("⏭️ [RepeaterFieldGroup] 같은 FK 값 - 스킵:", currentFkValueStr);
return;
}
prevFkValueRef.current = currentFkValueStr;
console.log("📥 [RepeaterFieldGroup] 분할 패널 FK 기반 데이터 로드:", {
fkColumn,
fkValue,
effectiveTargetTable,
});
setIsLoadingGroupData(true);
try {
// API 호출: FK 값을 기준으로 데이터 조회
const response = await apiClient.post(`/table-management/tables/${effectiveTargetTable}/data`, {
page: 1,
size: 100,
search: { [fkColumn]: fkValue },
});
if (response.data?.success) {
const items = response.data?.data?.data || [];
console.log("✅ [RepeaterFieldGroup] FK 기반 데이터 로드 완료:", {
count: items.length,
fkColumn,
fkValue,
effectiveTargetTable,
});
// 🆕 데이터가 있든 없든 항상 상태 업데이트 (빈 배열도 명확히 설정)
setGroupedData(items);
// 원본 데이터 ID 목록 저장
const itemIds = items.map((item: any) => String(item.id)).filter(Boolean);
setOriginalItemIds(itemIds);
// onChange 호출 (effectiveTargetTable 사용)
if (onChange) {
if (items.length > 0) {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: effectiveTargetTable,
_existingRecord: !!item.id,
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
}));
onChange(dataWithMeta);
} else {
// 🆕 데이터가 없으면 빈 배열 전달 (이전 데이터 클리어)
console.log(" [RepeaterFieldGroup] FK 기반 데이터 없음 - 빈 상태로 초기화");
onChange([]);
}
}
} else {
// API 실패 시 빈 배열로 설정
console.log("⚠️ [RepeaterFieldGroup] FK 기반 데이터 로드 실패 - 빈 상태로 초기화");
setGroupedData([]);
setOriginalItemIds([]);
onChange?.([]);
}
} catch (error) {
console.error("❌ [RepeaterFieldGroup] FK 기반 데이터 로드 오류:", error);
setGroupedData([]);
} finally {
setIsLoadingGroupData(false);
}
};
loadDataByFK();
}, [isRightPanel, fkColumn, fkValue, effectiveTargetTable, onChange]);
// 값이 JSON 문자열인 경우 파싱
let parsedValue: any[] = [];
// 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!)
// groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용
if (groupedData !== null) {
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 {
parsedValue = [];
}
} else if (Array.isArray(rawValue)) {
parsedValue = rawValue;
}
// parsedValue를 ref로 관리하여 최신 값 유지
const parsedValueRef = useRef(parsedValue);
parsedValueRef.current = parsedValue;
// onChange를 ref로 관리
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// onFormDataChange를 ref로 관리
const onFormDataChangeRef = useRef(onFormDataChange);
onFormDataChangeRef.current = onFormDataChange;
// fieldName을 ref로 관리
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 });
if (!data || data.length === 0) {
toast.warning("전달할 데이터가 없습니다");
return;
}
// 매핑 규칙이 배열인 경우에만 적용
let processedData = data;
if (Array.isArray(mappingRulesOrMode) && mappingRulesOrMode.length > 0) {
processedData = applyMappingRules(data, mappingRulesOrMode);
}
// 데이터 정규화: 각 항목에서 실제 데이터 추출
// 데이터가 {0: {...}, inbound_type: "..."} 형태인 경우 처리
const normalizedData = processedData.map((item: any) => {
// item이 {0: {...실제데이터...}, 추가필드: 값} 형태인 경우
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
// 0번 인덱스의 데이터와 나머지 필드를 병합
const { 0: originalData, ...additionalFields } = item;
return { ...originalData, ...additionalFields };
}
return item;
});
// 🆕 정의된 필드만 필터링 (불필요한 필드 제거)
// 반복 필드 그룹에 정의된 필드 + 시스템 필드만 유지
const definedFields = configRef.current.fields || [];
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
const systemFields = new Set([
"_targetTable",
"_isNewItem",
"created_date",
"updated_date",
"writer",
"company_code",
]);
const filteredData = normalizedData.map((item: any) => {
const filteredItem: Record<string, any> = {};
Object.keys(item).forEach((key) => {
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
if (key === "id") {
return; // id 필드 제외
}
// 정의된 필드이거나 시스템 필드인 경우만 포함
if (definedFieldNames.has(key) || systemFields.has(key)) {
filteredItem[key] = item[key];
}
});
// 🆕 새 항목임을 표시하는 플래그 추가
filteredItem._isNewItem = true;
return filteredItem;
});
console.log("📥 [RepeaterFieldGroup] 정규화된 데이터:", normalizedData);
console.log("📥 [RepeaterFieldGroup] 필터링된 데이터:", filteredData);
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
const currentValue = parsedValueRef.current;
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
let newItems: any[];
let addedCount = 0;
let duplicateCount = 0;
if (mode === "replace") {
newItems = filteredData;
addedCount = filteredData.length;
} else {
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
const existingItemCodes = new Set(currentValue.map((item: any) => item.item_code).filter(Boolean));
const uniqueNewItems = filteredData.filter((item: any) => {
const itemCode = item.item_code;
if (itemCode && existingItemCodes.has(itemCode)) {
duplicateCount++;
return false; // 중복 항목 제외
}
return true;
});
newItems = [...currentValue, ...uniqueNewItems];
addedCount = uniqueNewItems.length;
}
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
currentValue,
newItems,
mode,
addedCount,
duplicateCount,
});
// 🆕 groupedData 상태도 직접 업데이트 (UI 즉시 반영)
setGroupedData(newItems);
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
if (splitPanelContext?.addItemIds && addedCount > 0) {
const newItemCodes = newItems.map((item: any) => String(item.item_code)).filter(Boolean);
splitPanelContext.addItemIds(newItemCodes);
}
// JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newItems);
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
jsonValue,
hasOnChange: !!onChangeRef.current,
hasOnFormDataChange: !!onFormDataChangeRef.current,
fieldName: fieldNameRef.current,
});
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
if (onFormDataChangeRef.current) {
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
}
// 그렇지 않으면 onChange 사용
else if (onChangeRef.current) {
onChangeRef.current(jsonValue);
}
// 결과 메시지 표시
if (addedCount > 0) {
if (duplicateCount > 0) {
toast.success(`${addedCount}개 항목이 추가되었습니다 (${duplicateCount}개 중복 제외)`);
} else {
toast.success(`${addedCount}개 항목이 추가되었습니다`);
}
} else if (duplicateCount > 0) {
toast.warning(`${duplicateCount}개 항목이 이미 추가되어 있습니다`);
}
}, []);
// DataReceivable 인터페이스 구현
const dataReceiver = useMemo<DataReceivable>(
() => ({
componentId: component.id,
componentType: "repeater-field-group",
receiveData: handleReceiveData,
}),
[component.id, handleReceiveData],
);
// ScreenContext에 데이터 수신자로 등록
useEffect(() => {
if (screenContext && component.id) {
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
screenContext.registerDataReceiver(component.id, dataReceiver);
return () => {
screenContext.unregisterDataReceiver(component.id);
};
}
}, [screenContext, component.id, dataReceiver]);
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
useEffect(() => {
const splitPanelPosition = screenContext?.splitPanelPosition;
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
componentId: component.id,
position: splitPanelPosition,
});
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
receiverRef.current = dataReceiver;
return () => {
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
receiverRef.current = null;
};
}
}, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]);
// 🆕 전역 이벤트 리스너 (splitPanelDataTransfer)
useEffect(() => {
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
const { data, mode, mappingRules } = event.detail;
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
dataCount: data?.length,
mode,
componentId: component.id,
});
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
const splitPanelPosition = screenContext?.splitPanelPosition;
if (splitPanelPosition === "right" && data && data.length > 0) {
handleReceiveData(data, mappingRules || mode || "append");
}
};
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
return () => {
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
};
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
const handleRepeaterChange = useCallback(
(newValue: any[]) => {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
// 🆕 모든 항목에 메타데이터 추가
let valueWithMeta = newValue.map((item: any) => ({
...item,
_targetTable: effectiveTargetTable || targetTable,
_existingRecord: !!item.id,
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
}));
// 🆕 분할 패널에서 우측인 경우, FK 값 추가
if (isRightPanel && fkColumn && fkValue) {
valueWithMeta = valueWithMeta.map((item: any) => {
if (item._isNewItem) {
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", { fkColumn, fkValue });
return { ...item, [fkColumn]: fkValue };
}
return item;
});
}
// 배열을 JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(valueWithMeta);
console.log("📤 [RepeaterFieldGroup] 데이터 변경:", {
fieldName,
itemCount: valueWithMeta.length,
isRightPanel,
hasScreenContextUpdateFormData: !!screenContext?.updateFormData,
});
// 🆕 분할 패널 우측에서는 ScreenContext.updateFormData만 사용
// (중복 저장 방지: onChange/onFormDataChange는 부모에게 전달되어 다시 formData로 돌아옴)
if (isRightPanel && screenContext?.updateFormData) {
screenContext.updateFormData(fieldName, jsonValue);
console.log("📤 [RepeaterFieldGroup] screenContext.updateFormData 호출 (우측 패널):", { fieldName });
} else {
// 분할 패널이 아니거나 좌측 패널인 경우 기존 방식 사용
onChange?.(jsonValue);
if (onFormDataChange) {
onFormDataChange(fieldName, jsonValue);
console.log("📤 [RepeaterFieldGroup] onFormDataChange(props) 호출:", { fieldName });
}
}
// 🆕 groupedData 상태도 업데이트
setGroupedData(valueWithMeta);
// 🆕 SplitPanelContext의 addedItemIds 동기화
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
// 현재 항목들의 ID 목록
const currentIds = newValue
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
.filter(Boolean);
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
const addedIds = splitPanelContext.addedItemIds;
const removedIds = Array.from(addedIds).filter((id) => !currentIds.includes(id));
if (removedIds.length > 0) {
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
splitPanelContext.removeItemIds(removedIds);
}
// 새로 추가된 ID가 있으면 등록
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
if (newIds.length > 0) {
console.log(" [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
splitPanelContext.addItemIds(newIds);
}
}
},
[
onChange,
onFormDataChange,
splitPanelContext,
screenContext?.splitPanelPosition,
screenContext?.updateFormData,
isRightPanel,
effectiveTargetTable,
targetTable,
fkColumn,
fkValue,
fieldName,
],
);
// 🆕 config에 effectiveTargetTable 병합 (linkedFilters에서 추출된 테이블도 포함)
const effectiveConfig = {
...config,
targetTable: effectiveTargetTable || config.targetTable,
};
return (
<RepeaterInput
value={parsedValue}
onChange={handleRepeaterChange}
config={effectiveConfig}
disabled={disabled}
readonly={readonly}
menuObjid={menuObjid}
className="w-full"
/>
);
};
/**
* Repeater Field Group 렌더러
* 여러 필드를 가진 항목들을 동적으로 추가/제거할 수 있는 컴포넌트
*/
export class RepeaterFieldGroupRenderer extends AutoRegisteringComponentRenderer {
/**
* 컴포넌트 정의
*/
static componentDefinition: ComponentDefinition = {
id: "repeater-field-group",
name: "반복 필드 그룹",
nameEng: "Repeater Field Group",
description: "여러 필드를 가진 항목들을 동적으로 추가/제거할 수 있는 반복 가능한 필드 그룹",
category: ComponentCategory.INPUT,
webType: "array", // 배열 데이터를 다룸
icon: Layers,
component: RepeaterFieldGroupRenderer,
configPanel: RepeaterConfigPanel,
defaultSize: {
width: 600,
height: 200, // 기본 높이 조정
},
defaultConfig: {
fields: [], // 빈 배열로 시작 - 사용자가 직접 필드 추가
minItems: 1, // 기본 1개 항목
maxItems: 20,
addButtonText: "항목 추가",
allowReorder: true,
showIndex: true,
collapsible: false,
layout: "grid",
showDivider: true,
emptyMessage: "필드를 먼저 정의하세요.",
},
tags: ["repeater", "fieldgroup", "dynamic", "multi", "form", "array", "fields"],
author: "System",
version: "1.0.0",
};
/**
* 컴포넌트 렌더링
*/
render(): React.ReactElement {
return <RepeaterFieldGroupComponent {...this.props} />;
}
}
// 컴포넌트 자동 등록
RepeaterFieldGroupRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
RepeaterFieldGroupRenderer.enableHotReload();
}