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

497 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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[]>([]);
// 컴포넌트의 필드명 (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) => 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) {
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 (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 {
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));
// 시스템 필드 및 필수 필드 추가
const systemFields = new Set(['id', '_targetTable', 'created_date', 'updated_date', 'writer', 'company_code']);
const filteredData = normalizedData.map((item: any) => {
const filteredItem: Record<string, any> = {};
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";
let newItems: any[];
let addedCount = 0;
let duplicateCount = 0;
if (mode === "replace") {
newItems = filteredData;
addedCount = filteredData.length;
} else {
// 🆕 중복 체크: id 또는 고유 식별자를 기준으로 이미 존재하는 항목 제외
const existingIds = new Set(
currentValue
.map((item: any) => item.id || item.po_item_id || item.item_id)
.filter(Boolean)
);
const uniqueNewItems = filteredData.filter((item: any) => {
const itemId = item.id || item.po_item_id || item.item_id;
if (itemId && existingIds.has(itemId)) {
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 등록 (좌측 테이블 필터링용)
if (splitPanelContext?.addItemIds && addedCount > 0) {
const newItemIds = newItems
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
.filter(Boolean);
splitPanelContext.addItemIds(newItemIds);
}
// 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[]) => {
// 배열을 JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newValue);
onChange?.(jsonValue);
// 🆕 groupedData 상태도 업데이트
setGroupedData(newValue);
// 🆕 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, splitPanelContext, screenContext?.splitPanelPosition]);
return (
<RepeaterInput
value={parsedValue}
onChange={handleRepeaterChange}
config={config}
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();
}