Merge origin/main into ksh - resolve conflicts
This commit is contained in:
@@ -602,6 +602,9 @@ export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = (
|
||||
isInModal: _isInModal,
|
||||
isPreview: _isPreview,
|
||||
originalData: _originalData,
|
||||
_originalData: __originalData,
|
||||
_initialData: __initialData,
|
||||
_groupedData: __groupedData,
|
||||
allComponents: _allComponents,
|
||||
selectedRows: _selectedRows,
|
||||
selectedRowsData: _selectedRowsData,
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: any[];
|
||||
|
||||
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
@@ -57,10 +57,10 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||
flowSelectedData?: any[];
|
||||
flowSelectedStepId?: number | null;
|
||||
|
||||
|
||||
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
||||
allComponents?: any[];
|
||||
|
||||
|
||||
// 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용)
|
||||
groupedData?: Record<string, any>[];
|
||||
}
|
||||
@@ -109,11 +109,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||
// 🆕 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<void>) | undefined;
|
||||
const finalOnSave = onSave || propsOnSave;
|
||||
@@ -169,10 +169,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
if (!shouldFetchStatus) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
|
||||
page: 1,
|
||||
@@ -180,12 +180,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
search: { [statusKeyField]: userId },
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
|
||||
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
||||
const firstRow = Array.isArray(rows) ? rows[0] : null;
|
||||
|
||||
|
||||
if (response.data?.success && firstRow) {
|
||||
const newStatus = firstRow[statusFieldName];
|
||||
if (newStatus !== vehicleStatus) {
|
||||
@@ -206,10 +206,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
// 즉시 실행
|
||||
setStatusLoading(true);
|
||||
fetchStatus();
|
||||
|
||||
|
||||
// 2초마다 갱신
|
||||
const interval = setInterval(fetchStatus, 2000);
|
||||
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearInterval(interval);
|
||||
@@ -219,22 +219,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
// 버튼 비활성화 조건 계산
|
||||
const isOperationButtonDisabled = useMemo(() => {
|
||||
const actionConfig = component.componentConfig?.action;
|
||||
|
||||
|
||||
if (actionConfig?.type !== "operation_control") return false;
|
||||
|
||||
// 1. 출발지/도착지 필수 체크
|
||||
if (actionConfig?.requireLocationFields) {
|
||||
const departureField = actionConfig.trackingDepartureField || "departure";
|
||||
const destinationField = actionConfig.trackingArrivalField || "destination";
|
||||
|
||||
|
||||
const departure = formData?.[departureField];
|
||||
const destination = formData?.[destinationField];
|
||||
|
||||
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
|
||||
// departureField, destinationField, departure, destination,
|
||||
// buttonLabel: component.label
|
||||
|
||||
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
|
||||
// departureField, destinationField, departure, destination,
|
||||
// buttonLabel: component.label
|
||||
// });
|
||||
|
||||
|
||||
if (!departure || departure === "" || !destination || destination === "") {
|
||||
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
|
||||
return true;
|
||||
@@ -246,20 +246,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
const statusField = actionConfig.statusCheckField || "status";
|
||||
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
|
||||
const currentStatus = vehicleStatus || formData?.[statusField];
|
||||
|
||||
|
||||
const conditionType = actionConfig.statusConditionType || "enableOn";
|
||||
const conditionValues = (actionConfig.statusConditionValues || "")
|
||||
.split(",")
|
||||
.map((v: string) => v.trim())
|
||||
.filter((v: string) => v);
|
||||
|
||||
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
|
||||
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
|
||||
// statusField,
|
||||
// formDataStatus: formData?.[statusField],
|
||||
// apiStatus: vehicleStatus,
|
||||
// currentStatus,
|
||||
// conditionType,
|
||||
// conditionValues,
|
||||
// currentStatus,
|
||||
// conditionType,
|
||||
// conditionValues,
|
||||
// buttonLabel: component.label,
|
||||
// });
|
||||
|
||||
@@ -274,7 +274,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (conditionValues.length > 0) {
|
||||
if (conditionType === "enableOn") {
|
||||
// 이 상태일 때만 활성화
|
||||
@@ -551,7 +551,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
*/
|
||||
const handleTransferDataAction = async (actionConfig: any) => {
|
||||
const dataTransferConfig = actionConfig.dataTransfer;
|
||||
|
||||
|
||||
if (!dataTransferConfig) {
|
||||
toast.error("데이터 전달 설정이 없습니다.");
|
||||
return;
|
||||
@@ -565,15 +565,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
try {
|
||||
// 1. 소스 컴포넌트에서 데이터 가져오기
|
||||
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||
|
||||
|
||||
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
||||
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
||||
if (!sourceProvider) {
|
||||
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
||||
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
|
||||
|
||||
|
||||
const allProviders = screenContext.getAllDataProviders();
|
||||
|
||||
|
||||
// 테이블 리스트 우선 탐색
|
||||
for (const [id, provider] of allProviders) {
|
||||
if (provider.componentType === "table-list") {
|
||||
@@ -582,16 +582,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
||||
if (!sourceProvider && allProviders.size > 0) {
|
||||
const firstEntry = allProviders.entries().next().value;
|
||||
if (firstEntry) {
|
||||
sourceProvider = firstEntry[1];
|
||||
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`);
|
||||
console.log(
|
||||
`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!sourceProvider) {
|
||||
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
|
||||
return;
|
||||
@@ -599,12 +601,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
}
|
||||
|
||||
const rawSourceData = sourceProvider.getSelectedData();
|
||||
|
||||
|
||||
// 🆕 배열이 아닌 경우 배열로 변환
|
||||
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []);
|
||||
|
||||
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : [];
|
||||
|
||||
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
|
||||
|
||||
|
||||
if (!sourceData || sourceData.length === 0) {
|
||||
toast.warning("선택된 데이터가 없습니다.");
|
||||
return;
|
||||
@@ -612,31 +614,32 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
|
||||
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
|
||||
let additionalData: Record<string, any> = {};
|
||||
|
||||
|
||||
// 방법 1: additionalSources 설정에서 가져오기
|
||||
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
|
||||
for (const additionalSource of dataTransferConfig.additionalSources) {
|
||||
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
|
||||
|
||||
|
||||
if (additionalProvider) {
|
||||
const additionalValues = additionalProvider.getSelectedData();
|
||||
|
||||
|
||||
if (additionalValues && additionalValues.length > 0) {
|
||||
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
||||
const firstValue = additionalValues[0];
|
||||
|
||||
|
||||
// fieldName이 지정되어 있으면 그 필드만 추출
|
||||
if (additionalSource.fieldName) {
|
||||
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||
additionalData[additionalSource.fieldName] =
|
||||
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||
} else {
|
||||
// fieldName이 없으면 전체 객체 병합
|
||||
additionalData = { ...additionalData, ...firstValue };
|
||||
}
|
||||
|
||||
|
||||
console.log("📦 추가 데이터 수집 (additionalSources):", {
|
||||
sourceId: additionalSource.componentId,
|
||||
fieldName: additionalSource.fieldName,
|
||||
value: additionalData[additionalSource.fieldName || 'all'],
|
||||
value: additionalData[additionalSource.fieldName || "all"],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -651,7 +654,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
const conditionalValue = formData.__conditionalContainerValue;
|
||||
const conditionalLabel = formData.__conditionalContainerLabel;
|
||||
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
|
||||
|
||||
|
||||
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
|
||||
if (controlField) {
|
||||
additionalData[controlField] = conditionalValue;
|
||||
@@ -663,7 +666,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
} else {
|
||||
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
if (value === conditionalValue && !key.startsWith('__')) {
|
||||
if (value === conditionalValue && !key.startsWith("__")) {
|
||||
additionalData[key] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 자동 포함:", {
|
||||
fieldName: key,
|
||||
@@ -673,12 +676,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 못 찾았으면 기본 필드명 사용
|
||||
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) {
|
||||
additionalData['condition_type'] = conditionalValue;
|
||||
if (!Object.keys(additionalData).some((k) => !k.startsWith("__"))) {
|
||||
additionalData["condition_type"] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
|
||||
fieldName: 'condition_type',
|
||||
fieldName: "condition_type",
|
||||
value: conditionalValue,
|
||||
});
|
||||
}
|
||||
@@ -710,7 +713,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
||||
const mappedData = sourceData.map((row) => {
|
||||
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
||||
|
||||
|
||||
// 추가 데이터를 모든 행에 포함
|
||||
return {
|
||||
...mappedRow,
|
||||
@@ -730,7 +733,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
if (dataTransferConfig.targetType === "component") {
|
||||
// 같은 화면의 컴포넌트로 전달
|
||||
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
|
||||
|
||||
|
||||
if (!targetReceiver) {
|
||||
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
|
||||
return;
|
||||
@@ -742,7 +745,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
mode: dataTransferConfig.mode || "append",
|
||||
mappingRules: dataTransferConfig.mappingRules || [],
|
||||
});
|
||||
|
||||
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
} else if (dataTransferConfig.targetType === "splitPanel") {
|
||||
// 🆕 분할 패널의 반대편 화면으로 전달
|
||||
@@ -750,17 +753,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||||
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
|
||||
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
|
||||
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
|
||||
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
|
||||
|
||||
const currentPosition =
|
||||
splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
|
||||
|
||||
if (!currentPosition) {
|
||||
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log("📦 분할 패널 데이터 전달:", {
|
||||
currentPosition,
|
||||
splitPanelPositionFromHook: splitPanelPosition,
|
||||
@@ -768,14 +772,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
leftScreenId: splitPanelContext.leftScreenId,
|
||||
rightScreenId: splitPanelContext.rightScreenId,
|
||||
});
|
||||
|
||||
|
||||
const result = await splitPanelContext.transferToOtherSide(
|
||||
currentPosition,
|
||||
mappedData,
|
||||
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
|
||||
dataTransferConfig.mode || "append"
|
||||
dataTransferConfig.mode || "append",
|
||||
);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
@@ -794,7 +798,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
if (dataTransferConfig.clearAfterTransfer) {
|
||||
sourceProvider.clearSelection();
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ 데이터 전달 실패:", error);
|
||||
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
|
||||
@@ -828,16 +831,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
// 2. groupedData (부모창에서 모달로 전달된 데이터)
|
||||
// 3. modalDataStore (분할 패널 등에서 선택한 데이터)
|
||||
let effectiveSelectedRowsData = selectedRowsData;
|
||||
|
||||
|
||||
// groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근)
|
||||
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && groupedData && groupedData.length > 0) {
|
||||
if (
|
||||
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
|
||||
groupedData &&
|
||||
groupedData.length > 0
|
||||
) {
|
||||
effectiveSelectedRowsData = groupedData;
|
||||
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
|
||||
count: groupedData.length,
|
||||
data: groupedData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
||||
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
|
||||
try {
|
||||
@@ -845,11 +852,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
||||
const modalData = dataRegistry[effectiveTableName];
|
||||
if (modalData && modalData.length > 0) {
|
||||
effectiveSelectedRowsData = modalData;
|
||||
// modalDataStore는 {id, originalData, additionalData} 형태로 저장됨
|
||||
// originalData를 추출하여 실제 행 데이터를 가져옴
|
||||
effectiveSelectedRowsData = modalData.map((item: any) => {
|
||||
// originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성)
|
||||
return item.originalData || item;
|
||||
});
|
||||
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
|
||||
tableName: effectiveTableName,
|
||||
count: modalData.length,
|
||||
data: modalData,
|
||||
rawData: modalData,
|
||||
extractedData: effectiveSelectedRowsData,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -859,7 +872,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
|
||||
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
||||
const hasDataToDelete =
|
||||
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
||||
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) ||
|
||||
(flowSelectedData && flowSelectedData.length > 0);
|
||||
|
||||
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
|
||||
toast.warning("삭제할 항목을 먼저 선택해주세요.");
|
||||
@@ -905,8 +919,27 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 분할 패널 우측이면 screenContext.formData와 props.formData를 병합
|
||||
// screenContext.formData: RepeaterFieldGroup 등 컴포넌트가 직접 업데이트한 데이터
|
||||
// props.formData: 부모에서 전달된 폼 데이터
|
||||
const screenContextFormData = screenContext?.formData || {};
|
||||
const propsFormData = formData || {};
|
||||
|
||||
// 병합: props.formData를 기본으로 하고, screenContext.formData로 오버라이드
|
||||
// (RepeaterFieldGroup 데이터는 screenContext에만 있음)
|
||||
const effectiveFormData = { ...propsFormData, ...screenContextFormData };
|
||||
|
||||
console.log("🔍 [ButtonPrimary] formData 선택:", {
|
||||
hasScreenContextFormData: Object.keys(screenContextFormData).length > 0,
|
||||
screenContextKeys: Object.keys(screenContextFormData),
|
||||
hasPropsFormData: Object.keys(propsFormData).length > 0,
|
||||
propsFormDataKeys: Object.keys(propsFormData),
|
||||
splitPanelPosition,
|
||||
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
||||
});
|
||||
|
||||
const context: ButtonActionContext = {
|
||||
formData: formData || {},
|
||||
formData: effectiveFormData,
|
||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
@@ -996,6 +1029,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
flowSelectedStepId: _flowSelectedStepId, // 플로우 선택 스텝 ID 필터링
|
||||
onFlowRefresh: _onFlowRefresh, // 플로우 새로고침 콜백 필터링
|
||||
originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링
|
||||
_originalData: __originalData, // DOM 필터링
|
||||
_initialData: __initialData, // DOM 필터링
|
||||
_groupedData: __groupedData, // DOM 필터링
|
||||
refreshKey: _refreshKey, // 필터링 추가
|
||||
isInModal: _isInModal, // 필터링 추가
|
||||
mode: _mode, // 필터링 추가
|
||||
@@ -1073,15 +1109,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
// 🔧 크기에 따른 패딩 조정
|
||||
padding:
|
||||
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||
padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||
margin: "0",
|
||||
lineHeight: "1.25",
|
||||
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
||||
...(component.style ? Object.fromEntries(
|
||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
||||
) : {}),
|
||||
...(component.style
|
||||
? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
|
||||
: {}),
|
||||
};
|
||||
|
||||
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
||||
@@ -1103,7 +1138,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
<button
|
||||
type={componentConfig.actionType || "button"}
|
||||
disabled={finalDisabled}
|
||||
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
||||
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
|
||||
style={buttonElementStyle}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
|
||||
@@ -86,6 +86,9 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
||||
isInModal: _isInModal,
|
||||
readonly: _readonly,
|
||||
originalData: _originalData,
|
||||
_originalData: __originalData,
|
||||
_initialData: __initialData,
|
||||
_groupedData: __groupedData,
|
||||
allComponents: _allComponents,
|
||||
onUpdateLayout: _onUpdateLayout,
|
||||
selectedRows: _selectedRows,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { FileViewerModal } from "./FileViewerModal";
|
||||
import { FileManagerModal } from "./FileManagerModal";
|
||||
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
Upload,
|
||||
File,
|
||||
@@ -92,6 +93,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
onDragEnd,
|
||||
onUpdate,
|
||||
}) => {
|
||||
// 🔑 인증 정보 가져오기
|
||||
const { user } = useAuth();
|
||||
|
||||
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
|
||||
const [uploadStatus, setUploadStatus] = useState<FileUploadStatus>("idle");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
@@ -102,28 +106,94 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
||||
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
||||
const recordTableName = formData?.tableName || component.tableName;
|
||||
const recordId = formData?.id;
|
||||
// 🔑 컬럼명 결정: 레코드 모드에서는 무조건 'attachments' 사용
|
||||
// component.columnName이나 component.id는 '파일_업로드' 같은 한글 라벨일 수 있어서 DB 컬럼명으로 부적합
|
||||
// 레코드 모드가 아닐 때만 component.columnName 또는 component.id 사용
|
||||
const columnName = isRecordMode ? 'attachments' : (component.columnName || component.id || 'attachments');
|
||||
|
||||
// 🔑 레코드 모드용 targetObjid 생성
|
||||
const getRecordTargetObjid = useCallback(() => {
|
||||
if (isRecordMode && recordTableName && recordId) {
|
||||
return `${recordTableName}:${recordId}:${columnName}`;
|
||||
}
|
||||
return null;
|
||||
}, [isRecordMode, recordTableName, recordId, columnName]);
|
||||
|
||||
// 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용)
|
||||
const getUniqueKey = useCallback(() => {
|
||||
if (isRecordMode && recordTableName && recordId) {
|
||||
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성
|
||||
return `fileUpload_${recordTableName}_${recordId}_${component.id}`;
|
||||
}
|
||||
// 기본 모드: 컴포넌트 ID만 사용
|
||||
return `fileUpload_${component.id}`;
|
||||
}, [isRecordMode, recordTableName, recordId, component.id]);
|
||||
|
||||
// 🔍 디버깅: 레코드 모드 상태 로깅
|
||||
useEffect(() => {
|
||||
console.log("📎 [FileUploadComponent] 모드 확인:", {
|
||||
isRecordMode,
|
||||
recordTableName,
|
||||
recordId,
|
||||
columnName,
|
||||
targetObjid: getRecordTargetObjid(),
|
||||
uniqueKey: getUniqueKey(),
|
||||
formDataKeys: formData ? Object.keys(formData) : [],
|
||||
// 🔍 추가 디버깅: 어디서 tableName이 오는지 확인
|
||||
"formData.tableName": formData?.tableName,
|
||||
"component.tableName": component.tableName,
|
||||
"component.columnName": component.columnName,
|
||||
"component.id": component.id,
|
||||
});
|
||||
}, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]);
|
||||
|
||||
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
|
||||
const prevRecordIdRef = useRef<any>(null);
|
||||
useEffect(() => {
|
||||
if (prevRecordIdRef.current !== recordId) {
|
||||
console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", {
|
||||
prev: prevRecordIdRef.current,
|
||||
current: recordId,
|
||||
isRecordMode,
|
||||
});
|
||||
prevRecordIdRef.current = recordId;
|
||||
|
||||
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
|
||||
if (isRecordMode) {
|
||||
setUploadedFiles([]);
|
||||
}
|
||||
}
|
||||
}, [recordId, isRecordMode]);
|
||||
|
||||
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
|
||||
useEffect(() => {
|
||||
if (!component?.id) return;
|
||||
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
// 🔑 레코드별 고유 키 사용
|
||||
const backupKey = getUniqueKey();
|
||||
const backupFiles = localStorage.getItem(backupKey);
|
||||
if (backupFiles) {
|
||||
const parsedFiles = JSON.parse(backupFiles);
|
||||
if (parsedFiles.length > 0) {
|
||||
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
|
||||
uniqueKey: backupKey,
|
||||
componentId: component.id,
|
||||
recordId: recordId,
|
||||
restoredFiles: parsedFiles.length,
|
||||
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
|
||||
});
|
||||
setUploadedFiles(parsedFiles);
|
||||
|
||||
// 전역 상태에도 복원
|
||||
// 전역 상태에도 복원 (레코드별 고유 키 사용)
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).globalFileState = {
|
||||
...(window as any).globalFileState,
|
||||
[component.id]: parsedFiles,
|
||||
[backupKey]: parsedFiles,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -131,7 +201,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
} catch (e) {
|
||||
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
|
||||
}
|
||||
}, [component.id]); // component.id가 변경될 때만 실행
|
||||
}, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행
|
||||
|
||||
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
||||
useEffect(() => {
|
||||
@@ -152,12 +222,14 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
const newFiles = event.detail.files || [];
|
||||
setUploadedFiles(newFiles);
|
||||
|
||||
// localStorage 백업 업데이트
|
||||
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
const backupKey = getUniqueKey();
|
||||
localStorage.setItem(backupKey, JSON.stringify(newFiles));
|
||||
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
|
||||
uniqueKey: backupKey,
|
||||
componentId: component.id,
|
||||
recordId: recordId,
|
||||
fileCount: newFiles.length,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -201,6 +273,16 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
if (!component?.id) return false;
|
||||
|
||||
try {
|
||||
// 🔑 레코드 모드: 해당 행의 파일만 조회
|
||||
if (isRecordMode && recordTableName && recordId) {
|
||||
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
|
||||
tableName: recordTableName,
|
||||
recordId: recordId,
|
||||
columnName: columnName,
|
||||
targetObjid: getRecordTargetObjid(),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. formData에서 screenId 가져오기
|
||||
let screenId = formData?.screenId;
|
||||
|
||||
@@ -232,11 +314,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
const params = {
|
||||
screenId,
|
||||
componentId: component.id,
|
||||
tableName: formData?.tableName || component.tableName,
|
||||
recordId: formData?.id,
|
||||
columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용
|
||||
tableName: recordTableName || formData?.tableName || component.tableName,
|
||||
recordId: recordId || formData?.id,
|
||||
columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName
|
||||
};
|
||||
|
||||
console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params);
|
||||
|
||||
const response = await getComponentFiles(params);
|
||||
|
||||
if (response.success) {
|
||||
@@ -255,11 +339,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
}));
|
||||
|
||||
|
||||
// 🔄 localStorage의 기존 파일과 서버 파일 병합
|
||||
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
||||
let finalFiles = formattedFiles;
|
||||
const uniqueKey = getUniqueKey();
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
const backupFiles = localStorage.getItem(backupKey);
|
||||
const backupFiles = localStorage.getItem(uniqueKey);
|
||||
if (backupFiles) {
|
||||
const parsedBackupFiles = JSON.parse(backupFiles);
|
||||
|
||||
@@ -268,7 +352,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
|
||||
|
||||
finalFiles = [...formattedFiles, ...additionalFiles];
|
||||
|
||||
console.log("📂 [FileUploadComponent] 파일 병합 완료:", {
|
||||
uniqueKey,
|
||||
serverFiles: formattedFiles.length,
|
||||
localFiles: parsedBackupFiles.length,
|
||||
finalFiles: finalFiles.length,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("파일 병합 중 오류:", e);
|
||||
@@ -276,11 +365,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
|
||||
setUploadedFiles(finalFiles);
|
||||
|
||||
// 전역 상태에도 저장
|
||||
// 전역 상태에도 저장 (레코드별 고유 키 사용)
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).globalFileState = {
|
||||
...(window as any).globalFileState,
|
||||
[component.id]: finalFiles,
|
||||
[uniqueKey]: finalFiles,
|
||||
};
|
||||
|
||||
// 🌐 전역 파일 저장소에 등록 (페이지 간 공유용)
|
||||
@@ -288,12 +377,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
uploadPage: window.location.pathname,
|
||||
componentId: component.id,
|
||||
screenId: formData?.screenId,
|
||||
recordId: recordId,
|
||||
});
|
||||
|
||||
// localStorage 백업도 병합된 파일로 업데이트
|
||||
// localStorage 백업도 병합된 파일로 업데이트 (레코드별 고유 키 사용)
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
localStorage.setItem(backupKey, JSON.stringify(finalFiles));
|
||||
localStorage.setItem(uniqueKey, JSON.stringify(finalFiles));
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 업데이트 실패:", e);
|
||||
}
|
||||
@@ -304,7 +393,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
console.error("파일 조회 오류:", error);
|
||||
}
|
||||
return false; // 기존 로직 사용
|
||||
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]);
|
||||
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]);
|
||||
|
||||
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
|
||||
useEffect(() => {
|
||||
@@ -316,6 +405,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
componentFiles: componentFiles.length,
|
||||
formData: formData,
|
||||
screenId: formData?.screenId,
|
||||
tableName: formData?.tableName, // 🔍 테이블명 확인
|
||||
recordId: formData?.id, // 🔍 레코드 ID 확인
|
||||
currentUploadedFiles: uploadedFiles.length,
|
||||
});
|
||||
|
||||
@@ -371,9 +462,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
setUploadedFiles(files);
|
||||
setForceUpdate((prev) => prev + 1);
|
||||
|
||||
// localStorage 백업도 업데이트
|
||||
// localStorage 백업도 업데이트 (레코드별 고유 키 사용)
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
const backupKey = getUniqueKey();
|
||||
localStorage.setItem(backupKey, JSON.stringify(files));
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 실패:", e);
|
||||
@@ -462,10 +553,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
|
||||
|
||||
try {
|
||||
// targetObjid 생성 - 템플릿 vs 데이터 파일 구분
|
||||
const tableName = formData?.tableName || component.tableName || "default_table";
|
||||
const recordId = formData?.id;
|
||||
const columnName = component.columnName || component.id;
|
||||
// 🔑 레코드 모드 우선 사용
|
||||
const effectiveTableName = recordTableName || formData?.tableName || component.tableName || "default_table";
|
||||
const effectiveRecordId = recordId || formData?.id;
|
||||
const effectiveColumnName = columnName;
|
||||
|
||||
// screenId 추출 (우선순위: formData > URL)
|
||||
let screenId = formData?.screenId;
|
||||
@@ -478,47 +569,84 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
}
|
||||
|
||||
let targetObjid;
|
||||
// 우선순위: 1) 실제 데이터 (recordId가 숫자/문자열이고 temp_가 아님) > 2) 템플릿 (screenId) > 3) 기본값
|
||||
const isRealRecord = recordId && typeof recordId !== 'undefined' && !String(recordId).startsWith('temp_');
|
||||
// 🔑 레코드 모드 판단 개선
|
||||
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_'));
|
||||
|
||||
if (isRealRecord && tableName) {
|
||||
// 실제 데이터 파일 (진짜 레코드 ID가 있을 때만)
|
||||
targetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||
console.log("📁 실제 데이터 파일 업로드:", targetObjid);
|
||||
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
|
||||
// 🎯 레코드 모드: 특정 행에 파일 연결
|
||||
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
|
||||
console.log("📁 [레코드 모드] 파일 업로드:", {
|
||||
targetObjid,
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
});
|
||||
} else if (screenId) {
|
||||
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
|
||||
targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`;
|
||||
targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`;
|
||||
console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid);
|
||||
} else {
|
||||
// 기본값 (화면관리에서 사용)
|
||||
targetObjid = `temp_${component.id}`;
|
||||
console.log("📝 기본 파일 업로드:", targetObjid);
|
||||
console.log("📝 [기본 모드] 파일 업로드:", targetObjid);
|
||||
}
|
||||
|
||||
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
|
||||
const userCompanyCode = (window as any).__user__?.companyCode;
|
||||
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
|
||||
|
||||
console.log("📤 [FileUploadComponent] 파일 업로드 준비:", {
|
||||
userCompanyCode,
|
||||
isRecordMode: effectiveIsRecordMode,
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
targetObjid,
|
||||
});
|
||||
|
||||
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
|
||||
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
|
||||
const finalLinkedTable = effectiveIsRecordMode
|
||||
? effectiveTableName
|
||||
: (formData?.linkedTable || effectiveTableName);
|
||||
|
||||
const uploadData = {
|
||||
// 🎯 formData에서 백엔드 API 설정 가져오기
|
||||
autoLink: formData?.autoLink || true,
|
||||
linkedTable: formData?.linkedTable || tableName,
|
||||
recordId: formData?.recordId || recordId || `temp_${component.id}`,
|
||||
columnName: formData?.columnName || columnName,
|
||||
linkedTable: finalLinkedTable,
|
||||
recordId: effectiveRecordId || `temp_${component.id}`,
|
||||
columnName: effectiveColumnName,
|
||||
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
|
||||
docType: component.fileConfig?.docType || "DOCUMENT",
|
||||
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
|
||||
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
|
||||
// 호환성을 위한 기존 필드들
|
||||
tableName: tableName,
|
||||
fieldName: columnName,
|
||||
tableName: effectiveTableName,
|
||||
fieldName: effectiveColumnName,
|
||||
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
||||
// 🆕 레코드 모드 플래그
|
||||
isRecordMode: effectiveIsRecordMode,
|
||||
};
|
||||
|
||||
console.log("📤 [FileUploadComponent] uploadData 최종:", {
|
||||
isRecordMode: effectiveIsRecordMode,
|
||||
linkedTable: finalLinkedTable,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
targetObjid,
|
||||
});
|
||||
|
||||
|
||||
console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", {
|
||||
filesCount: filesToUpload.length,
|
||||
uploadData,
|
||||
});
|
||||
|
||||
const response = await uploadFiles({
|
||||
files: filesToUpload,
|
||||
...uploadData,
|
||||
});
|
||||
|
||||
console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response);
|
||||
|
||||
if (response.success) {
|
||||
// FileUploadResponse 타입에 맞게 files 배열 사용
|
||||
@@ -553,9 +681,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
setUploadedFiles(updatedFiles);
|
||||
setUploadStatus("success");
|
||||
|
||||
// localStorage 백업
|
||||
// localStorage 백업 (레코드별 고유 키 사용)
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
const backupKey = getUniqueKey();
|
||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 실패:", e);
|
||||
@@ -563,9 +691,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
|
||||
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||
if (typeof window !== "undefined") {
|
||||
// 전역 파일 상태 업데이트
|
||||
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
|
||||
const globalFileState = (window as any).globalFileState || {};
|
||||
globalFileState[component.id] = updatedFiles;
|
||||
const uniqueKey = getUniqueKey();
|
||||
globalFileState[uniqueKey] = updatedFiles;
|
||||
(window as any).globalFileState = globalFileState;
|
||||
|
||||
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
|
||||
@@ -573,12 +702,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
uploadPage: window.location.pathname,
|
||||
componentId: component.id,
|
||||
screenId: formData?.screenId,
|
||||
recordId: recordId, // 🆕 레코드 ID 추가
|
||||
});
|
||||
|
||||
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
uniqueKey: uniqueKey, // 🆕 고유 키 추가
|
||||
recordId: recordId, // 🆕 레코드 ID 추가
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
timestamp: Date.now(),
|
||||
@@ -612,22 +744,54 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
||||
}
|
||||
|
||||
// 🆕 레코드 모드: attachments 컬럼 동기화 (formData 업데이트)
|
||||
if (effectiveIsRecordMode && onFormDataChange) {
|
||||
// 파일 정보를 간소화하여 attachments 컬럼에 저장할 형태로 변환
|
||||
const attachmentsData = updatedFiles.map(file => ({
|
||||
objid: file.objid,
|
||||
realFileName: file.realFileName,
|
||||
fileSize: file.fileSize,
|
||||
fileExt: file.fileExt,
|
||||
filePath: file.filePath,
|
||||
regdate: file.regdate || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
console.log("📎 [레코드 모드] attachments 컬럼 동기화:", {
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
fileCount: attachmentsData.length,
|
||||
});
|
||||
|
||||
// onFormDataChange를 통해 부모 컴포넌트에 attachments 업데이트 알림
|
||||
onFormDataChange({
|
||||
[effectiveColumnName]: attachmentsData,
|
||||
// 🆕 백엔드에서 attachments 컬럼 업데이트를 위한 메타 정보
|
||||
__attachmentsUpdate: {
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
files: attachmentsData,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
const refreshEvent = new CustomEvent("refreshFileStatus", {
|
||||
detail: {
|
||||
tableName: tableName,
|
||||
recordId: recordId,
|
||||
columnName: columnName,
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
targetObjid: targetObjid,
|
||||
fileCount: updatedFiles.length,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(refreshEvent);
|
||||
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
|
||||
tableName,
|
||||
recordId,
|
||||
columnName,
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
targetObjid,
|
||||
fileCount: updatedFiles.length,
|
||||
});
|
||||
@@ -705,9 +869,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId);
|
||||
setUploadedFiles(updatedFiles);
|
||||
|
||||
// localStorage 백업 업데이트
|
||||
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
const backupKey = getUniqueKey();
|
||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 업데이트 실패:", e);
|
||||
@@ -715,15 +879,18 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
|
||||
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||
if (typeof window !== "undefined") {
|
||||
// 전역 파일 상태 업데이트
|
||||
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
|
||||
const globalFileState = (window as any).globalFileState || {};
|
||||
globalFileState[component.id] = updatedFiles;
|
||||
const uniqueKey = getUniqueKey();
|
||||
globalFileState[uniqueKey] = updatedFiles;
|
||||
(window as any).globalFileState = globalFileState;
|
||||
|
||||
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
uniqueKey: uniqueKey, // 🆕 고유 키 추가
|
||||
recordId: recordId, // 🆕 레코드 ID 추가
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
timestamp: Date.now(),
|
||||
@@ -749,13 +916,42 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 레코드 모드: attachments 컬럼 동기화 (파일 삭제 후)
|
||||
if (isRecordMode && onFormDataChange && recordTableName && recordId) {
|
||||
const attachmentsData = updatedFiles.map(f => ({
|
||||
objid: f.objid,
|
||||
realFileName: f.realFileName,
|
||||
fileSize: f.fileSize,
|
||||
fileExt: f.fileExt,
|
||||
filePath: f.filePath,
|
||||
regdate: f.regdate || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
console.log("📎 [레코드 모드] 파일 삭제 후 attachments 동기화:", {
|
||||
tableName: recordTableName,
|
||||
recordId: recordId,
|
||||
columnName: columnName,
|
||||
remainingFiles: attachmentsData.length,
|
||||
});
|
||||
|
||||
onFormDataChange({
|
||||
[columnName]: attachmentsData,
|
||||
__attachmentsUpdate: {
|
||||
tableName: recordTableName,
|
||||
recordId: recordId,
|
||||
columnName: columnName,
|
||||
files: attachmentsData,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(`${fileName} 삭제 완료`);
|
||||
} catch (error) {
|
||||
console.error("파일 삭제 오류:", error);
|
||||
toast.error("파일 삭제에 실패했습니다.");
|
||||
}
|
||||
},
|
||||
[uploadedFiles, onUpdate, component.id],
|
||||
[uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey],
|
||||
);
|
||||
|
||||
// 대표 이미지 Blob URL 로드
|
||||
|
||||
@@ -53,6 +53,9 @@ export interface LocationSwapSelectorProps {
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (field: string, value: any) => void;
|
||||
|
||||
// 🆕 사용자 정보 (DB에서 초기값 로드용)
|
||||
userId?: string;
|
||||
|
||||
// componentConfig (화면 디자이너에서 전달)
|
||||
componentConfig?: {
|
||||
dataSource?: DataSourceConfig;
|
||||
@@ -65,6 +68,10 @@ export interface LocationSwapSelectorProps {
|
||||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
variant?: "card" | "inline" | "minimal";
|
||||
// 🆕 DB 초기값 로드 설정
|
||||
loadFromDb?: boolean; // DB에서 초기값 로드 여부
|
||||
dbTableName?: string; // 조회할 테이블명 (기본: vehicles)
|
||||
dbKeyField?: string; // 키 필드 (기본: user_id)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +87,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||
formData = {},
|
||||
onFormDataChange,
|
||||
componentConfig,
|
||||
userId,
|
||||
} = props;
|
||||
|
||||
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
||||
@@ -93,6 +101,11 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
||||
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
||||
const variant = config.variant || props.variant || "card";
|
||||
|
||||
// 🆕 DB 초기값 로드 설정
|
||||
const loadFromDb = config.loadFromDb !== false; // 기본값 true
|
||||
const dbTableName = config.dbTableName || "vehicles";
|
||||
const dbKeyField = config.dbKeyField || "user_id";
|
||||
|
||||
// 기본 옵션 (포항/광양)
|
||||
const DEFAULT_OPTIONS: LocationOption[] = [
|
||||
@@ -104,6 +117,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSwapping, setIsSwapping] = useState(false);
|
||||
const [dbLoaded, setDbLoaded] = useState(false); // DB 로드 완료 여부
|
||||
|
||||
// 로컬 선택 상태 (Select 컴포넌트용)
|
||||
const [localDeparture, setLocalDeparture] = useState<string>("");
|
||||
@@ -193,8 +207,89 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||
loadOptions();
|
||||
}, [dataSource, isDesignMode]);
|
||||
|
||||
// formData에서 초기값 동기화
|
||||
// 🆕 DB에서 초기값 로드 (새로고침 시에도 출발지/목적지 유지)
|
||||
useEffect(() => {
|
||||
const loadFromDatabase = async () => {
|
||||
// 디자인 모드이거나, DB 로드 비활성화이거나, userId가 없으면 스킵
|
||||
if (isDesignMode || !loadFromDb || !userId) {
|
||||
console.log("[LocationSwapSelector] DB 로드 스킵:", { isDesignMode, loadFromDb, userId });
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 로드했으면 스킵
|
||||
if (dbLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[LocationSwapSelector] DB에서 출발지/목적지 로드 시작:", { dbTableName, dbKeyField, userId });
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${dbTableName}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 1,
|
||||
search: { [dbKeyField]: userId },
|
||||
autoFilter: true,
|
||||
}
|
||||
);
|
||||
|
||||
const vehicleData = response.data?.data?.data?.[0] || response.data?.data?.rows?.[0];
|
||||
|
||||
if (vehicleData) {
|
||||
const dbDeparture = vehicleData[departureField] || vehicleData.departure;
|
||||
const dbDestination = vehicleData[destinationField] || vehicleData.arrival || vehicleData.destination;
|
||||
|
||||
console.log("[LocationSwapSelector] DB에서 로드된 값:", { dbDeparture, dbDestination });
|
||||
|
||||
// DB에 값이 있으면 로컬 상태 및 formData 업데이트
|
||||
if (dbDeparture && options.some(o => o.value === dbDeparture)) {
|
||||
setLocalDeparture(dbDeparture);
|
||||
onFormDataChange?.(departureField, dbDeparture);
|
||||
|
||||
// 라벨도 업데이트
|
||||
if (departureLabelField) {
|
||||
const opt = options.find(o => o.value === dbDeparture);
|
||||
if (opt) {
|
||||
onFormDataChange?.(departureLabelField, opt.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dbDestination && options.some(o => o.value === dbDestination)) {
|
||||
setLocalDestination(dbDestination);
|
||||
onFormDataChange?.(destinationField, dbDestination);
|
||||
|
||||
// 라벨도 업데이트
|
||||
if (destinationLabelField) {
|
||||
const opt = options.find(o => o.value === dbDestination);
|
||||
if (opt) {
|
||||
onFormDataChange?.(destinationLabelField, opt.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDbLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("[LocationSwapSelector] DB 로드 실패:", error);
|
||||
setDbLoaded(true); // 실패해도 다시 시도하지 않음
|
||||
}
|
||||
};
|
||||
|
||||
// 옵션이 로드된 후에 DB 로드 실행
|
||||
if (options.length > 0) {
|
||||
loadFromDatabase();
|
||||
}
|
||||
}, [userId, loadFromDb, dbTableName, dbKeyField, departureField, destinationField, options, isDesignMode, dbLoaded, onFormDataChange, departureLabelField, destinationLabelField]);
|
||||
|
||||
// formData에서 초기값 동기화 (DB 로드 후에도 formData 변경 시 반영)
|
||||
useEffect(() => {
|
||||
// DB 로드가 완료되지 않았으면 스킵 (DB 값 우선)
|
||||
if (loadFromDb && userId && !dbLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const depVal = formData[departureField];
|
||||
const destVal = formData[destinationField];
|
||||
|
||||
@@ -204,7 +299,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||
if (destVal && options.some(o => o.value === destVal)) {
|
||||
setLocalDestination(destVal);
|
||||
}
|
||||
}, [formData, departureField, destinationField, options]);
|
||||
}, [formData, departureField, destinationField, options, loadFromDb, userId, dbLoaded]);
|
||||
|
||||
// 출발지 변경
|
||||
const handleDepartureChange = (selectedValue: string) => {
|
||||
|
||||
@@ -470,6 +470,58 @@ export function LocationSwapSelectorConfigPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB 초기값 로드 설정 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">DB 초기값 로드</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
새로고침 시에도 DB에 저장된 출발지/목적지를 자동으로 불러옵니다
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>DB에서 초기값 로드</Label>
|
||||
<Switch
|
||||
checked={config?.loadFromDb !== false}
|
||||
onCheckedChange={(checked) => handleChange("loadFromDb", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config?.loadFromDb !== false && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>조회 테이블</Label>
|
||||
<Select
|
||||
value={config?.dbTableName || "vehicles"}
|
||||
onValueChange={(value) => handleChange("dbTableName", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vehicles">vehicles (기본)</SelectItem>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name}>
|
||||
{table.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>키 필드</Label>
|
||||
<Input
|
||||
value={config?.dbKeyField || "user_id"}
|
||||
onChange={(e) => handleChange("dbKeyField", e.target.value)}
|
||||
placeholder="user_id"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
현재 사용자 ID로 조회할 필드 (기본: user_id)
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
@@ -480,6 +532,8 @@ export function LocationSwapSelectorConfigPanel({
|
||||
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
||||
<br />
|
||||
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
||||
<br />
|
||||
4. DB 초기값 로드를 활성화하면 새로고침 후에도 값이 유지됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,11 +52,15 @@ export function RepeatScreenModalComponent({
|
||||
config,
|
||||
className,
|
||||
groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터
|
||||
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지)
|
||||
_initialData,
|
||||
_originalData: _propsOriginalData,
|
||||
_groupedData,
|
||||
...props
|
||||
}: RepeatScreenModalComponentProps) {
|
||||
}: RepeatScreenModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
|
||||
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
|
||||
// DynamicComponentRenderer에서는 _groupedData로 전달됨
|
||||
const groupedData = propsGroupedData || (props as any).groupedData || (props as any)._groupedData;
|
||||
const groupedData = propsGroupedData || (props as any).groupedData || _groupedData;
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component?.config,
|
||||
|
||||
@@ -20,24 +20,56 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (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 config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||
const groupByColumn = config.groupByColumn;
|
||||
const targetTable = config.targetTable;
|
||||
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;
|
||||
@@ -45,21 +77,127 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
// 🆕 수정 모드 감지: 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);
|
||||
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,
|
||||
|
||||
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
||||
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,
|
||||
@@ -72,8 +210,24 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
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,
|
||||
});
|
||||
|
||||
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
||||
@@ -82,16 +236,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
// 이미 로드했거나 조건이 맞지 않으면 스킵
|
||||
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)
|
||||
@@ -100,14 +254,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
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; // 실제 데이터 배열
|
||||
@@ -118,17 +272,17 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
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) => ({
|
||||
@@ -150,15 +304,126 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
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) {
|
||||
const dataWithMeta = items.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable,
|
||||
_existingRecord: !!item.id,
|
||||
}));
|
||||
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[] = [];
|
||||
|
||||
// 🆕 그룹화된 데이터가 있으면 우선 사용
|
||||
if (groupedData !== null && groupedData.length > 0) {
|
||||
|
||||
// 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!)
|
||||
// groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용
|
||||
if (groupedData !== null) {
|
||||
parsedValue = groupedData;
|
||||
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
||||
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
||||
@@ -201,7 +466,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
// 데이터 수신 핸들러
|
||||
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
||||
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
||||
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
toast.warning("전달할 데이터가 없습니다");
|
||||
return;
|
||||
@@ -230,13 +495,20 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
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 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 => {
|
||||
Object.keys(item).forEach((key) => {
|
||||
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
|
||||
if (key === 'id') {
|
||||
if (key === "id") {
|
||||
return; // id 필드 제외
|
||||
}
|
||||
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
||||
@@ -254,25 +526,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
|
||||
// 기존 데이터에 새 데이터 추가 (기본 모드: 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 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)) {
|
||||
@@ -281,14 +549,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
newItems = [...currentValue, ...uniqueNewItems];
|
||||
addedCount = uniqueNewItems.length;
|
||||
}
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
||||
currentValue,
|
||||
newItems,
|
||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
||||
currentValue,
|
||||
newItems,
|
||||
mode,
|
||||
addedCount,
|
||||
duplicateCount,
|
||||
@@ -300,21 +568,19 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
||||
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
|
||||
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
||||
const newItemCodes = newItems
|
||||
.map((item: any) => String(item.item_code))
|
||||
.filter(Boolean);
|
||||
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,
|
||||
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);
|
||||
@@ -337,18 +603,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
}, []);
|
||||
|
||||
// DataReceivable 인터페이스 구현
|
||||
const dataReceiver = useMemo<DataReceivable>(() => ({
|
||||
componentId: component.id,
|
||||
componentType: "repeater-field-group",
|
||||
receiveData: handleReceiveData,
|
||||
}), [component.id, handleReceiveData]);
|
||||
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);
|
||||
};
|
||||
@@ -358,16 +627,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
// 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);
|
||||
@@ -380,13 +649,13 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
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) {
|
||||
@@ -395,51 +664,113 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||
};
|
||||
|
||||
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);
|
||||
const handleRepeaterChange = useCallback(
|
||||
(newValue: any[]) => {
|
||||
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
|
||||
let valueWithMeta = newValue;
|
||||
|
||||
if (isRightPanel && effectiveTargetTable) {
|
||||
valueWithMeta = newValue.map((item: any) => {
|
||||
const itemWithMeta = {
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable,
|
||||
};
|
||||
|
||||
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
|
||||
if (fkColumn && fkValue && item._isNewItem) {
|
||||
itemWithMeta[fkColumn] = fkValue;
|
||||
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
|
||||
fkColumn,
|
||||
fkValue,
|
||||
});
|
||||
}
|
||||
|
||||
return itemWithMeta;
|
||||
});
|
||||
}
|
||||
|
||||
// 새로 추가된 ID가 있으면 등록
|
||||
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
||||
if (newIds.length > 0) {
|
||||
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
||||
splitPanelContext.addItemIds(newIds);
|
||||
|
||||
// 배열을 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [onChange, splitPanelContext, screenContext?.splitPanelPosition]);
|
||||
|
||||
// 🆕 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,
|
||||
fkColumn,
|
||||
fkValue,
|
||||
fieldName,
|
||||
],
|
||||
);
|
||||
|
||||
// 🆕 config에 effectiveTargetTable 병합 (linkedFilters에서 추출된 테이블도 포함)
|
||||
const effectiveConfig = {
|
||||
...config,
|
||||
targetTable: effectiveTargetTable || config.targetTable,
|
||||
};
|
||||
|
||||
return (
|
||||
<RepeaterInput
|
||||
value={parsedValue}
|
||||
onChange={handleRepeaterChange}
|
||||
config={config}
|
||||
config={effectiveConfig}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
menuObjid={menuObjid}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* SplitPanelResize Context 타입 정의
|
||||
* 분할 패널의 드래그 리사이즈 상태를 외부 컴포넌트(버튼 등)와 공유하기 위한 Context
|
||||
*
|
||||
* 주의: contexts/SplitPanelContext.tsx는 데이터 전달용 Context이고,
|
||||
* 이 Context는 드래그 리사이즈 시 버튼 위치 조정을 위한 별도 Context입니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 분할 패널 정보 (컴포넌트 좌표 기준)
|
||||
*/
|
||||
export interface SplitPanelInfo {
|
||||
id: string;
|
||||
// 분할 패널의 좌표 (스크린 캔버스 기준, px)
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// 좌측 패널 비율 (0-100)
|
||||
leftWidthPercent: number;
|
||||
// 초기 좌측 패널 비율 (드래그 시작 시점)
|
||||
initialLeftWidthPercent: number;
|
||||
// 드래그 중 여부
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export interface SplitPanelResizeContextValue {
|
||||
// 등록된 분할 패널들
|
||||
splitPanels: Map<string, SplitPanelInfo>;
|
||||
|
||||
// 분할 패널 등록/해제/업데이트
|
||||
registerSplitPanel: (id: string, info: Omit<SplitPanelInfo, "id">) => void;
|
||||
unregisterSplitPanel: (id: string) => void;
|
||||
updateSplitPanel: (id: string, updates: Partial<SplitPanelInfo>) => void;
|
||||
|
||||
// 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
|
||||
// 반환값: { panelId, offsetX } 또는 null
|
||||
getOverlappingSplitPanel: (
|
||||
componentX: number,
|
||||
componentY: number,
|
||||
componentWidth: number,
|
||||
componentHeight: number,
|
||||
) => { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null;
|
||||
|
||||
// 컴포넌트의 조정된 X 좌표 계산
|
||||
// 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
|
||||
getAdjustedX: (componentX: number, componentY: number, componentWidth: number, componentHeight: number) => number;
|
||||
|
||||
// 레거시 호환 (단일 분할 패널용)
|
||||
leftWidthPercent: number;
|
||||
containerRect: DOMRect | null;
|
||||
dividerX: number;
|
||||
isDragging: boolean;
|
||||
splitPanelId: string | null;
|
||||
updateLeftWidth: (percent: number) => void;
|
||||
updateContainerRect: (rect: DOMRect | null) => void;
|
||||
updateDragging: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
// Context 생성
|
||||
const SplitPanelResizeContext = createContext<SplitPanelResizeContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* SplitPanelResize Context Provider
|
||||
* 스크린 빌더 레벨에서 감싸서 사용
|
||||
*/
|
||||
export const SplitPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// 등록된 분할 패널들
|
||||
const splitPanelsRef = useRef<Map<string, SplitPanelInfo>>(new Map());
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// 레거시 호환용 상태
|
||||
const [legacyLeftWidthPercent, setLegacyLeftWidthPercent] = useState(30);
|
||||
const [legacyContainerRect, setLegacyContainerRect] = useState<DOMRect | null>(null);
|
||||
const [legacyIsDragging, setLegacyIsDragging] = useState(false);
|
||||
const [legacySplitPanelId, setLegacySplitPanelId] = useState<string | null>(null);
|
||||
|
||||
// 분할 패널 등록
|
||||
const registerSplitPanel = useCallback((id: string, info: Omit<SplitPanelInfo, "id">) => {
|
||||
splitPanelsRef.current.set(id, { id, ...info });
|
||||
setLegacySplitPanelId(id);
|
||||
setLegacyLeftWidthPercent(info.leftWidthPercent);
|
||||
forceUpdate((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
// 분할 패널 해제
|
||||
const unregisterSplitPanel = useCallback(
|
||||
(id: string) => {
|
||||
splitPanelsRef.current.delete(id);
|
||||
if (legacySplitPanelId === id) {
|
||||
setLegacySplitPanelId(null);
|
||||
}
|
||||
forceUpdate((n) => n + 1);
|
||||
},
|
||||
[legacySplitPanelId],
|
||||
);
|
||||
|
||||
// 분할 패널 업데이트
|
||||
const updateSplitPanel = useCallback((id: string, updates: Partial<SplitPanelInfo>) => {
|
||||
const panel = splitPanelsRef.current.get(id);
|
||||
if (panel) {
|
||||
const updatedPanel = { ...panel, ...updates };
|
||||
splitPanelsRef.current.set(id, updatedPanel);
|
||||
|
||||
// 레거시 호환 상태 업데이트
|
||||
if (updates.leftWidthPercent !== undefined) {
|
||||
setLegacyLeftWidthPercent(updates.leftWidthPercent);
|
||||
}
|
||||
if (updates.isDragging !== undefined) {
|
||||
setLegacyIsDragging(updates.isDragging);
|
||||
}
|
||||
|
||||
forceUpdate((n) => n + 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
|
||||
*/
|
||||
const getOverlappingSplitPanel = useCallback(
|
||||
(
|
||||
componentX: number,
|
||||
componentY: number,
|
||||
componentWidth: number,
|
||||
componentHeight: number,
|
||||
): { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null => {
|
||||
for (const [panelId, panel] of splitPanelsRef.current) {
|
||||
// 컴포넌트의 중심점
|
||||
const componentCenterX = componentX + componentWidth / 2;
|
||||
const componentCenterY = componentY + componentHeight / 2;
|
||||
|
||||
// 컴포넌트가 분할 패널 영역 내에 있는지 확인
|
||||
const isInPanelX = componentCenterX >= panel.x && componentCenterX <= panel.x + panel.width;
|
||||
const isInPanelY = componentCenterY >= panel.y && componentCenterY <= panel.y + panel.height;
|
||||
|
||||
if (isInPanelX && isInPanelY) {
|
||||
// 좌측 패널의 현재 너비 (px)
|
||||
const leftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
// 좌측 패널 경계 (분할 패널 기준 상대 좌표)
|
||||
const dividerX = panel.x + leftPanelWidth;
|
||||
|
||||
// 컴포넌트 중심이 좌측 패널 내에 있는지 확인
|
||||
const isInLeftPanel = componentCenterX < dividerX;
|
||||
|
||||
return { panelId, panel, isInLeftPanel };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 컴포넌트의 조정된 X 좌표 계산
|
||||
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
|
||||
*
|
||||
* 핵심 로직:
|
||||
* - 버튼의 원래 X 좌표가 초기 좌측 패널 너비 내에서 어느 비율에 있는지 계산
|
||||
* - 드래그로 좌측 패널 너비가 바뀌면, 같은 비율을 유지하도록 X 좌표 조정
|
||||
*/
|
||||
const getAdjustedX = useCallback(
|
||||
(componentX: number, componentY: number, componentWidth: number, componentHeight: number): number => {
|
||||
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
if (!overlap || !overlap.isInLeftPanel) {
|
||||
// 분할 패널 위에 없거나, 우측 패널 위에 있으면 원래 위치 유지
|
||||
return componentX;
|
||||
}
|
||||
|
||||
const { panel } = overlap;
|
||||
|
||||
// 초기 좌측 패널 너비 (설정된 splitRatio 기준)
|
||||
const initialLeftPanelWidth = (panel.width * panel.initialLeftWidthPercent) / 100;
|
||||
// 현재 좌측 패널 너비 (드래그로 변경된 값)
|
||||
const currentLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
|
||||
// 변화가 없으면 원래 위치 반환
|
||||
if (Math.abs(initialLeftPanelWidth - currentLeftPanelWidth) < 1) {
|
||||
return componentX;
|
||||
}
|
||||
|
||||
// 컴포넌트의 분할 패널 내 상대 X 좌표
|
||||
const relativeX = componentX - panel.x;
|
||||
|
||||
// 좌측 패널 내에서의 비율 (0~1)
|
||||
const ratioInLeftPanel = relativeX / initialLeftPanelWidth;
|
||||
|
||||
// 조정된 상대 X 좌표 = 원래 비율 * 현재 좌측 패널 너비
|
||||
const adjustedRelativeX = ratioInLeftPanel * currentLeftPanelWidth;
|
||||
|
||||
// 절대 X 좌표로 변환
|
||||
const adjustedX = panel.x + adjustedRelativeX;
|
||||
|
||||
console.log("📍 [SplitPanel] 버튼 위치 조정:", {
|
||||
componentX,
|
||||
panelX: panel.x,
|
||||
relativeX,
|
||||
initialLeftPanelWidth,
|
||||
currentLeftPanelWidth,
|
||||
ratioInLeftPanel,
|
||||
adjustedX,
|
||||
delta: adjustedX - componentX,
|
||||
});
|
||||
|
||||
return adjustedX;
|
||||
},
|
||||
[getOverlappingSplitPanel],
|
||||
);
|
||||
|
||||
// 레거시 호환 - dividerX 계산
|
||||
const legacyDividerX = legacyContainerRect ? (legacyContainerRect.width * legacyLeftWidthPercent) / 100 : 0;
|
||||
|
||||
// 레거시 호환 함수들
|
||||
const updateLeftWidth = useCallback((percent: number) => {
|
||||
setLegacyLeftWidthPercent(percent);
|
||||
// 첫 번째 분할 패널 업데이트
|
||||
const firstPanelId = splitPanelsRef.current.keys().next().value;
|
||||
if (firstPanelId) {
|
||||
const panel = splitPanelsRef.current.get(firstPanelId);
|
||||
if (panel) {
|
||||
splitPanelsRef.current.set(firstPanelId, { ...panel, leftWidthPercent: percent });
|
||||
}
|
||||
}
|
||||
forceUpdate((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const updateContainerRect = useCallback((rect: DOMRect | null) => {
|
||||
setLegacyContainerRect(rect);
|
||||
}, []);
|
||||
|
||||
const updateDragging = useCallback((dragging: boolean) => {
|
||||
setLegacyIsDragging(dragging);
|
||||
// 첫 번째 분할 패널 업데이트
|
||||
const firstPanelId = splitPanelsRef.current.keys().next().value;
|
||||
if (firstPanelId) {
|
||||
const panel = splitPanelsRef.current.get(firstPanelId);
|
||||
if (panel) {
|
||||
// 드래그 시작 시 초기 비율 저장
|
||||
const updates: Partial<SplitPanelInfo> = { isDragging: dragging };
|
||||
if (dragging) {
|
||||
updates.initialLeftWidthPercent = panel.leftWidthPercent;
|
||||
}
|
||||
splitPanelsRef.current.set(firstPanelId, { ...panel, ...updates });
|
||||
}
|
||||
}
|
||||
forceUpdate((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<SplitPanelResizeContextValue>(
|
||||
() => ({
|
||||
splitPanels: splitPanelsRef.current,
|
||||
registerSplitPanel,
|
||||
unregisterSplitPanel,
|
||||
updateSplitPanel,
|
||||
getOverlappingSplitPanel,
|
||||
getAdjustedX,
|
||||
// 레거시 호환
|
||||
leftWidthPercent: legacyLeftWidthPercent,
|
||||
containerRect: legacyContainerRect,
|
||||
dividerX: legacyDividerX,
|
||||
isDragging: legacyIsDragging,
|
||||
splitPanelId: legacySplitPanelId,
|
||||
updateLeftWidth,
|
||||
updateContainerRect,
|
||||
updateDragging,
|
||||
}),
|
||||
[
|
||||
registerSplitPanel,
|
||||
unregisterSplitPanel,
|
||||
updateSplitPanel,
|
||||
getOverlappingSplitPanel,
|
||||
getAdjustedX,
|
||||
legacyLeftWidthPercent,
|
||||
legacyContainerRect,
|
||||
legacyDividerX,
|
||||
legacyIsDragging,
|
||||
legacySplitPanelId,
|
||||
updateLeftWidth,
|
||||
updateContainerRect,
|
||||
updateDragging,
|
||||
],
|
||||
);
|
||||
|
||||
return <SplitPanelResizeContext.Provider value={value}>{children}</SplitPanelResizeContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* SplitPanelResize Context 사용 훅
|
||||
* 분할 패널의 드래그 리사이즈 상태를 구독합니다.
|
||||
*/
|
||||
export const useSplitPanel = (): SplitPanelResizeContextValue => {
|
||||
const context = useContext(SplitPanelResizeContext);
|
||||
|
||||
// Context가 없으면 기본값 반환 (Provider 외부에서 사용 시)
|
||||
if (!context) {
|
||||
return {
|
||||
splitPanels: new Map(),
|
||||
registerSplitPanel: () => {},
|
||||
unregisterSplitPanel: () => {},
|
||||
updateSplitPanel: () => {},
|
||||
getOverlappingSplitPanel: () => null,
|
||||
getAdjustedX: (x) => x,
|
||||
leftWidthPercent: 30,
|
||||
containerRect: null,
|
||||
dividerX: 0,
|
||||
isDragging: false,
|
||||
splitPanelId: null,
|
||||
updateLeftWidth: () => {},
|
||||
updateContainerRect: () => {},
|
||||
updateDragging: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 조정된 위치를 계산하는 훅
|
||||
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 X 좌표가 조정됨
|
||||
*
|
||||
* @param componentX - 컴포넌트의 X 좌표 (px)
|
||||
* @param componentY - 컴포넌트의 Y 좌표 (px)
|
||||
* @param componentWidth - 컴포넌트 너비 (px)
|
||||
* @param componentHeight - 컴포넌트 높이 (px)
|
||||
* @returns 조정된 X 좌표와 관련 정보
|
||||
*/
|
||||
export const useAdjustedComponentPosition = (
|
||||
componentX: number,
|
||||
componentY: number,
|
||||
componentWidth: number,
|
||||
componentHeight: number,
|
||||
) => {
|
||||
const context = useSplitPanel();
|
||||
|
||||
const adjustedX = context.getAdjustedX(componentX, componentY, componentWidth, componentHeight);
|
||||
const overlap = context.getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
return {
|
||||
adjustedX,
|
||||
isInSplitPanel: !!overlap,
|
||||
isInLeftPanel: overlap?.isInLeftPanel ?? false,
|
||||
isDragging: overlap?.panel.isDragging ?? false,
|
||||
panelId: overlap?.panelId ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 버튼 등 외부 컴포넌트에서 분할 패널 좌측 영역 내 위치를 계산하는 훅 (레거시 호환)
|
||||
*/
|
||||
export const useAdjustedPosition = (originalXPercent: number) => {
|
||||
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
|
||||
|
||||
const isInLeftPanel = originalXPercent <= leftWidthPercent;
|
||||
const adjustedXPercent = isInLeftPanel ? (originalXPercent / 100) * leftWidthPercent : originalXPercent;
|
||||
const adjustedXPx = containerRect ? (containerRect.width * adjustedXPercent) / 100 : 0;
|
||||
|
||||
return {
|
||||
adjustedXPercent,
|
||||
adjustedXPx,
|
||||
isInLeftPanel,
|
||||
isDragging,
|
||||
dividerX,
|
||||
containerRect,
|
||||
leftWidthPercent,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 버튼이 좌측 패널 위에 배치되었을 때, 드래그에 따라 위치가 조정되는 스타일을 반환하는 훅 (레거시 호환)
|
||||
*/
|
||||
export const useSplitPanelAwarePosition = (
|
||||
initialLeftPercent: number,
|
||||
options?: {
|
||||
followDivider?: boolean;
|
||||
offset?: number;
|
||||
},
|
||||
) => {
|
||||
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
|
||||
const { followDivider = false, offset = 0 } = options || {};
|
||||
|
||||
if (followDivider) {
|
||||
return {
|
||||
left: containerRect ? `${dividerX + offset}px` : `${leftWidthPercent}%`,
|
||||
transition: isDragging ? "none" : "left 0.15s ease-out",
|
||||
};
|
||||
}
|
||||
|
||||
const adjustedLeft = (initialLeftPercent / 100) * leftWidthPercent;
|
||||
|
||||
return {
|
||||
left: `${adjustedLeft}%`,
|
||||
transition: isDragging ? "none" : "left 0.15s ease-out",
|
||||
};
|
||||
};
|
||||
|
||||
export default SplitPanelResizeContext;
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -34,8 +34,9 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSplitPanel } from "./SplitPanelContext";
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
@@ -73,12 +74,81 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
return true;
|
||||
};
|
||||
|
||||
// 🆕 엔티티 조인 컬럼명 변환 헬퍼
|
||||
// "테이블명.컬럼명" 형식을 "원본컬럼_조인컬럼명" 형식으로 변환하여 데이터 접근
|
||||
const getEntityJoinValue = useCallback(
|
||||
(item: any, columnName: string, entityColumnMap?: Record<string, string>): any => {
|
||||
// 직접 매칭 시도
|
||||
if (item[columnName] !== undefined) {
|
||||
return item[columnName];
|
||||
}
|
||||
|
||||
// "테이블명.컬럼명" 형식인 경우 (예: item_info.item_name)
|
||||
if (columnName.includes(".")) {
|
||||
const [tableName, fieldName] = columnName.split(".");
|
||||
|
||||
// 🔍 엔티티 조인 컬럼 값 추출
|
||||
// 예: item_info.item_name, item_info.standard, item_info.unit
|
||||
|
||||
// 1️⃣ 소스 컬럼 추론 (item_info → item_code, warehouse_info → warehouse_id 등)
|
||||
const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id");
|
||||
|
||||
// 2️⃣ 정확한 키 매핑 시도: 소스컬럼_필드명
|
||||
// 예: item_code_item_name, item_code_standard, item_code_unit
|
||||
const exactKey = `${inferredSourceColumn}_${fieldName}`;
|
||||
if (item[exactKey] !== undefined) {
|
||||
return item[exactKey];
|
||||
}
|
||||
|
||||
// 🆕 2-1️⃣ item_id 패턴 시도 (백엔드가 item_id_xxx 형식으로 반환하는 경우)
|
||||
// 예: item_info.item_name → item_id_item_name
|
||||
const idPatternKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
|
||||
if (item[idPatternKey] !== undefined) {
|
||||
return item[idPatternKey];
|
||||
}
|
||||
|
||||
// 3️⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용)
|
||||
// 예: item_code_name (item_name의 별칭)
|
||||
if (fieldName === "item_name" || fieldName === "name") {
|
||||
const aliasKey = `${inferredSourceColumn}_name`;
|
||||
if (item[aliasKey] !== undefined) {
|
||||
return item[aliasKey];
|
||||
}
|
||||
// 🆕 item_id_name 패턴도 시도
|
||||
const idAliasKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_name`;
|
||||
if (item[idAliasKey] !== undefined) {
|
||||
return item[idAliasKey];
|
||||
}
|
||||
}
|
||||
|
||||
// 4️⃣ entityColumnMap에서 매핑 찾기 (화면 설정에서 지정된 경우)
|
||||
if (entityColumnMap && entityColumnMap[tableName]) {
|
||||
const sourceColumn = entityColumnMap[tableName];
|
||||
const joinedColumnName = `${sourceColumn}_${fieldName}`;
|
||||
if (item[joinedColumnName] !== undefined) {
|
||||
return item[joinedColumnName];
|
||||
}
|
||||
}
|
||||
|
||||
// 5️⃣ 테이블명_컬럼명 형식으로 시도
|
||||
const underscoreKey = `${tableName}_${fieldName}`;
|
||||
if (item[underscoreKey] !== undefined) {
|
||||
return item[underscoreKey];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// TableOptions Context
|
||||
const { registerTable, unregisterTable } = useTableOptions();
|
||||
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
|
||||
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
|
||||
const [leftColumnVisibility, setLeftColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||
const [leftColumnOrder, setLeftColumnOrder] = useState<string[]>([]); // 🔧 컬럼 순서
|
||||
const [leftGroupSumConfig, setLeftGroupSumConfig] = useState<GroupSumConfig | null>(null); // 🆕 그룹별 합산 설정
|
||||
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
|
||||
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
||||
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||
@@ -125,6 +195,202 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 🆕 SplitPanel Resize Context 연동 (버튼 등 외부 컴포넌트와 드래그 리사이즈 상태 공유)
|
||||
const splitPanelContext = useSplitPanel();
|
||||
const {
|
||||
registerSplitPanel: ctxRegisterSplitPanel,
|
||||
unregisterSplitPanel: ctxUnregisterSplitPanel,
|
||||
updateSplitPanel: ctxUpdateSplitPanel,
|
||||
} = splitPanelContext;
|
||||
const splitPanelId = `split-panel-${component.id}`;
|
||||
|
||||
// 디버깅: Context 연결 상태 확인
|
||||
console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
|
||||
componentId: component.id,
|
||||
splitPanelId,
|
||||
hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
|
||||
splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
|
||||
});
|
||||
|
||||
// Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행
|
||||
const ctxRegisterRef = useRef(ctxRegisterSplitPanel);
|
||||
const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel);
|
||||
ctxRegisterRef.current = ctxRegisterSplitPanel;
|
||||
ctxUnregisterRef.current = ctxUnregisterSplitPanel;
|
||||
|
||||
useEffect(() => {
|
||||
// 컴포넌트의 위치와 크기 정보
|
||||
const panelX = component.position?.x || 0;
|
||||
const panelY = component.position?.y || 0;
|
||||
const panelWidth = component.size?.width || component.style?.width || 800;
|
||||
const panelHeight = component.size?.height || component.style?.height || 600;
|
||||
|
||||
const panelInfo = {
|
||||
x: panelX,
|
||||
y: panelY,
|
||||
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
|
||||
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
|
||||
leftWidthPercent: splitRatio, // 초기값은 splitRatio 사용
|
||||
initialLeftWidthPercent: splitRatio,
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
|
||||
splitPanelId,
|
||||
panelInfo,
|
||||
});
|
||||
|
||||
ctxRegisterRef.current(splitPanelId, panelInfo);
|
||||
|
||||
return () => {
|
||||
console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
|
||||
ctxUnregisterRef.current(splitPanelId);
|
||||
};
|
||||
// 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [splitPanelId]);
|
||||
|
||||
// 위치/크기 변경 시 Context 업데이트 (등록 후)
|
||||
const ctxUpdateRef = useRef(ctxUpdateSplitPanel);
|
||||
ctxUpdateRef.current = ctxUpdateSplitPanel;
|
||||
|
||||
useEffect(() => {
|
||||
const panelX = component.position?.x || 0;
|
||||
const panelY = component.position?.y || 0;
|
||||
const panelWidth = component.size?.width || component.style?.width || 800;
|
||||
const panelHeight = component.size?.height || component.style?.height || 600;
|
||||
|
||||
ctxUpdateRef.current(splitPanelId, {
|
||||
x: panelX,
|
||||
y: panelY,
|
||||
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
|
||||
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
|
||||
});
|
||||
}, [
|
||||
splitPanelId,
|
||||
component.position?.x,
|
||||
component.position?.y,
|
||||
component.size?.width,
|
||||
component.size?.height,
|
||||
component.style?.width,
|
||||
component.style?.height,
|
||||
]);
|
||||
|
||||
// leftWidth 변경 시 Context 업데이트
|
||||
useEffect(() => {
|
||||
ctxUpdateRef.current(splitPanelId, { leftWidthPercent: leftWidth });
|
||||
}, [leftWidth, splitPanelId]);
|
||||
|
||||
// 드래그 상태 변경 시 Context 업데이트
|
||||
// 이전 드래그 상태를 추적하여 드래그 종료 시점을 감지
|
||||
const prevIsDraggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const wasJustDragging = prevIsDraggingRef.current && !isDragging;
|
||||
|
||||
if (isDragging) {
|
||||
// 드래그 시작 시: 현재 비율을 초기 비율로 저장
|
||||
ctxUpdateRef.current(splitPanelId, {
|
||||
isDragging: true,
|
||||
initialLeftWidthPercent: leftWidth,
|
||||
});
|
||||
} else if (wasJustDragging) {
|
||||
// 드래그 종료 시: 최종 비율을 초기 비율로 업데이트 (버튼 위치 고정)
|
||||
ctxUpdateRef.current(splitPanelId, {
|
||||
isDragging: false,
|
||||
initialLeftWidthPercent: leftWidth,
|
||||
});
|
||||
console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", {
|
||||
splitPanelId,
|
||||
finalLeftWidthPercent: leftWidth,
|
||||
});
|
||||
}
|
||||
|
||||
prevIsDraggingRef.current = isDragging;
|
||||
}, [isDragging, splitPanelId, leftWidth]);
|
||||
|
||||
// 🆕 그룹별 합산된 데이터 계산
|
||||
const summedLeftData = useMemo(() => {
|
||||
console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);
|
||||
|
||||
// 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환
|
||||
if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) {
|
||||
console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환");
|
||||
return leftData;
|
||||
}
|
||||
|
||||
const groupByColumn = leftGroupSumConfig.groupByColumn;
|
||||
const groupMap = new Map<string, any>();
|
||||
|
||||
// 조인 컬럼인지 확인하고 실제 키 추론
|
||||
const getActualKey = (columnName: string, item: any): string => {
|
||||
if (columnName.includes(".")) {
|
||||
const [refTable, fieldName] = columnName.split(".");
|
||||
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
||||
const exactKey = `${inferredSourceColumn}_${fieldName}`;
|
||||
console.log("🔍 [그룹합산] 조인 컬럼 키 변환:", { columnName, exactKey, hasKey: item[exactKey] !== undefined });
|
||||
if (item[exactKey] !== undefined) return exactKey;
|
||||
if (fieldName === "item_name" || fieldName === "name") {
|
||||
const aliasKey = `${inferredSourceColumn}_name`;
|
||||
if (item[aliasKey] !== undefined) return aliasKey;
|
||||
}
|
||||
}
|
||||
return columnName;
|
||||
};
|
||||
|
||||
// 숫자 타입인지 확인하는 함수
|
||||
const isNumericValue = (value: any): boolean => {
|
||||
if (value === null || value === undefined || value === "") return false;
|
||||
const num = parseFloat(String(value));
|
||||
return !isNaN(num) && isFinite(num);
|
||||
};
|
||||
|
||||
// 그룹핑 수행
|
||||
leftData.forEach((item) => {
|
||||
const actualKey = getActualKey(groupByColumn, item);
|
||||
const groupValue = String(item[actualKey] || item[groupByColumn] || "");
|
||||
|
||||
// 원본 ID 추출 (id, ID, 또는 첫 번째 값)
|
||||
const originalId = item.id || item.ID || Object.values(item)[0];
|
||||
|
||||
if (!groupMap.has(groupValue)) {
|
||||
// 첫 번째 항목을 기준으로 초기화 + 원본 ID 배열 + 원본 데이터 배열
|
||||
groupMap.set(groupValue, {
|
||||
...item,
|
||||
_groupCount: 1,
|
||||
_originalIds: [originalId],
|
||||
_originalItems: [item], // 🆕 원본 데이터 전체 저장
|
||||
});
|
||||
} else {
|
||||
const existing = groupMap.get(groupValue);
|
||||
existing._groupCount += 1;
|
||||
existing._originalIds.push(originalId);
|
||||
existing._originalItems.push(item); // 🆕 원본 데이터 추가
|
||||
|
||||
// 모든 키에 대해 숫자면 합산
|
||||
Object.keys(item).forEach((key) => {
|
||||
const value = item[key];
|
||||
if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) {
|
||||
const numValue = parseFloat(String(value));
|
||||
const existingValue = parseFloat(String(existing[key] || 0));
|
||||
existing[key] = existingValue + numValue;
|
||||
}
|
||||
});
|
||||
|
||||
groupMap.set(groupValue, existing);
|
||||
}
|
||||
});
|
||||
|
||||
const result = Array.from(groupMap.values());
|
||||
console.log("🔗 [분할패널] 그룹별 합산 결과:", {
|
||||
원본개수: leftData.length,
|
||||
그룹개수: result.length,
|
||||
그룹기준: groupByColumn,
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [leftData, leftGroupSumConfig]);
|
||||
|
||||
// 컴포넌트 스타일
|
||||
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
|
||||
const getHeightValue = () => {
|
||||
@@ -433,14 +699,81 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
|
||||
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
|
||||
|
||||
// 🆕 "테이블명.컬럼명" 형식의 조인 컬럼들을 additionalJoinColumns로 변환
|
||||
const configuredColumns = componentConfig.leftPanel?.columns || [];
|
||||
const additionalJoinColumns: Array<{
|
||||
sourceTable: string;
|
||||
sourceColumn: string;
|
||||
referenceTable: string;
|
||||
joinAlias: string;
|
||||
}> = [];
|
||||
|
||||
// 소스 컬럼 매핑 (item_info → item_code, warehouse_info → warehouse_id 등)
|
||||
const sourceColumnMap: Record<string, string> = {};
|
||||
|
||||
configuredColumns.forEach((col: any) => {
|
||||
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
||||
if (colName && colName.includes(".")) {
|
||||
const [refTable, refColumn] = colName.split(".");
|
||||
// 소스 컬럼 추론 (item_info → item_code 또는 warehouse_info → warehouse_id)
|
||||
// 기본: _info → _code, 백업: _info → _id
|
||||
const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
||||
const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id");
|
||||
// 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달)
|
||||
const inferredSourceColumn = primarySourceColumn;
|
||||
|
||||
// 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼)
|
||||
const existingJoin = additionalJoinColumns.find(
|
||||
(j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn,
|
||||
);
|
||||
|
||||
if (!existingJoin) {
|
||||
// 새로운 조인 추가 (첫 번째 컬럼)
|
||||
additionalJoinColumns.push({
|
||||
sourceTable: leftTableName,
|
||||
sourceColumn: inferredSourceColumn,
|
||||
referenceTable: refTable,
|
||||
joinAlias: `${inferredSourceColumn}_${refColumn}`,
|
||||
});
|
||||
sourceColumnMap[refTable] = inferredSourceColumn;
|
||||
}
|
||||
|
||||
// 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등)
|
||||
// 단, 첫 번째 컬럼과 다른 경우만
|
||||
const existingAliases = additionalJoinColumns
|
||||
.filter((j) => j.referenceTable === refTable)
|
||||
.map((j) => j.joinAlias);
|
||||
const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`;
|
||||
|
||||
if (!existingAliases.includes(newAlias)) {
|
||||
additionalJoinColumns.push({
|
||||
sourceTable: leftTableName,
|
||||
sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn,
|
||||
referenceTable: refTable,
|
||||
joinAlias: newAlias,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns);
|
||||
console.log("🔗 [분할패널] configuredColumns:", configuredColumns);
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
||||
page: 1,
|
||||
size: 100,
|
||||
search: filters, // 필터 조건 전달
|
||||
enableEntityJoin: true, // 엔티티 조인 활성화
|
||||
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
|
||||
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼
|
||||
});
|
||||
|
||||
// 🔍 디버깅: API 응답 데이터의 키 확인
|
||||
if (result.data && result.data.length > 0) {
|
||||
console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
|
||||
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
|
||||
}
|
||||
|
||||
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
if (leftColumn && result.data.length > 0) {
|
||||
@@ -466,6 +799,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
}
|
||||
}, [
|
||||
componentConfig.leftPanel?.tableName,
|
||||
componentConfig.leftPanel?.columns,
|
||||
componentConfig.leftPanel?.dataFilter,
|
||||
componentConfig.rightPanel?.relation?.leftColumn,
|
||||
isDesignMode,
|
||||
toast,
|
||||
@@ -502,6 +837,68 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const keys = componentConfig.rightPanel?.relation?.keys;
|
||||
const leftTable = componentConfig.leftPanel?.tableName;
|
||||
|
||||
// 🆕 그룹 합산된 항목인 경우: 원본 데이터들로 우측 패널 표시
|
||||
if (leftItem._originalItems && leftItem._originalItems.length > 0) {
|
||||
console.log("🔗 [분할패널] 그룹 합산 항목 - 원본 개수:", leftItem._originalItems.length);
|
||||
|
||||
// 정렬 기준 컬럼 (복합키의 leftColumn들)
|
||||
const sortColumns = keys?.map((k: any) => k.leftColumn).filter(Boolean) || [];
|
||||
console.log("🔗 [분할패널] 정렬 기준 컬럼:", sortColumns);
|
||||
|
||||
// 정렬 함수
|
||||
const sortByKeys = (data: any[]) => {
|
||||
if (sortColumns.length === 0) return data;
|
||||
return [...data].sort((a, b) => {
|
||||
for (const col of sortColumns) {
|
||||
const aVal = String(a[col] || "");
|
||||
const bVal = String(b[col] || "");
|
||||
const cmp = aVal.localeCompare(bVal, "ko-KR");
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
// 원본 데이터를 그대로 우측 패널에 표시 (이력 테이블과 동일 테이블인 경우)
|
||||
if (leftTable === rightTableName) {
|
||||
const sortedData = sortByKeys(leftItem._originalItems);
|
||||
console.log("🔗 [분할패널] 동일 테이블 - 정렬된 원본 데이터:", sortedData.length);
|
||||
setRightData(sortedData);
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 테이블인 경우: 원본 ID들로 조회
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
const allResults: any[] = [];
|
||||
|
||||
// 각 원본 항목에 대해 조회
|
||||
for (const originalItem of leftItem._originalItems) {
|
||||
const searchConditions: Record<string, any> = {};
|
||||
keys?.forEach((key: any) => {
|
||||
if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) {
|
||||
searchConditions[key.rightColumn] = originalItem[key.leftColumn];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(searchConditions).length > 0) {
|
||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||
search: searchConditions,
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
});
|
||||
if (result.data) {
|
||||
allResults.push(...result.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬 적용
|
||||
const sortedResults = sortByKeys(allResults);
|
||||
console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length);
|
||||
setRightData(sortedResults);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 복합키 지원
|
||||
if (keys && keys.length > 0 && leftTable) {
|
||||
// 복합키: 여러 조건으로 필터링
|
||||
@@ -642,7 +1039,39 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const uniqueValues = new Set<string>();
|
||||
|
||||
leftData.forEach((item) => {
|
||||
const value = item[columnName];
|
||||
// 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard 또는 item_id_standard)
|
||||
let value: any;
|
||||
|
||||
if (columnName.includes(".")) {
|
||||
// 조인 컬럼: getEntityJoinValue와 동일한 로직 적용
|
||||
const [refTable, fieldName] = columnName.split(".");
|
||||
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
||||
|
||||
// 정확한 키로 먼저 시도
|
||||
const exactKey = `${inferredSourceColumn}_${fieldName}`;
|
||||
value = item[exactKey];
|
||||
|
||||
// 🆕 item_id 패턴 시도
|
||||
if (value === undefined) {
|
||||
const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
|
||||
value = item[idPatternKey];
|
||||
}
|
||||
|
||||
// 기본 별칭 패턴 시도 (item_code_name 또는 item_id_name)
|
||||
if (value === undefined && (fieldName === "item_name" || fieldName === "name")) {
|
||||
const aliasKey = `${inferredSourceColumn}_name`;
|
||||
value = item[aliasKey];
|
||||
// item_id_name 패턴도 시도
|
||||
if (value === undefined) {
|
||||
const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`;
|
||||
value = item[idAliasKey];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 일반 컬럼
|
||||
value = item[columnName];
|
||||
}
|
||||
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
// _name 필드 우선 사용 (category/entity type)
|
||||
const displayValue = item[`${columnName}_name`] || value;
|
||||
@@ -666,6 +1095,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
const leftTableId = `split-panel-left-${component.id}`;
|
||||
// 🔧 화면에 표시되는 컬럼 사용 (columns 속성)
|
||||
const configuredColumns = componentConfig.leftPanel?.columns || [];
|
||||
|
||||
// 🆕 설정에서 지정한 라벨 맵 생성
|
||||
const configuredLabels: Record<string, string> = {};
|
||||
configuredColumns.forEach((col: any) => {
|
||||
if (typeof col === "object" && col.name && col.label) {
|
||||
configuredLabels[col.name] = col.label;
|
||||
}
|
||||
});
|
||||
|
||||
const displayColumns = configuredColumns
|
||||
.map((col: any) => {
|
||||
if (typeof col === "string") return col;
|
||||
@@ -683,7 +1121,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
tableName: leftTableName,
|
||||
columns: displayColumns.map((col: string) => ({
|
||||
columnName: col,
|
||||
columnLabel: leftColumnLabels[col] || col,
|
||||
// 🆕 우선순위: 1) 설정에서 지정한 라벨 2) DB 라벨 3) 컬럼명
|
||||
columnLabel: configuredLabels[col] || leftColumnLabels[col] || col,
|
||||
inputType: "text",
|
||||
visible: true,
|
||||
width: 150,
|
||||
@@ -695,6 +1134,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
onColumnVisibilityChange: setLeftColumnVisibility,
|
||||
onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가
|
||||
getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가
|
||||
onGroupSumChange: setLeftGroupSumConfig, // 🆕 그룹별 합산 설정 콜백
|
||||
});
|
||||
|
||||
return () => unregisterTable(leftTableId);
|
||||
@@ -1651,16 +2091,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
// 🆕 그룹별 합산된 데이터 사용
|
||||
const dataSource = summedLeftData;
|
||||
console.log(
|
||||
"🔍 [테이블모드 렌더링] dataSource 개수:",
|
||||
dataSource.length,
|
||||
"leftGroupSumConfig:",
|
||||
leftGroupSumConfig,
|
||||
);
|
||||
|
||||
// 🔧 로컬 검색 필터 적용
|
||||
const filteredData = leftSearchQuery
|
||||
? leftData.filter((item) => {
|
||||
? dataSource.filter((item) => {
|
||||
const searchLower = leftSearchQuery.toLowerCase();
|
||||
return Object.entries(item).some(([key, value]) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
});
|
||||
})
|
||||
: leftData;
|
||||
: dataSource;
|
||||
|
||||
// 🔧 가시성 처리된 컬럼 사용
|
||||
const columnsToShow =
|
||||
@@ -1737,7 +2186,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
>
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
item[col.name],
|
||||
getEntityJoinValue(item, col.name),
|
||||
leftCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
@@ -1796,7 +2245,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{formatCellValue(col.name, item[col.name], leftCategoryMappings, col.format)}
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
leftCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@@ -1851,16 +2305,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
// 🆕 그룹별 합산된 데이터 사용
|
||||
const dataToDisplay = summedLeftData;
|
||||
console.log(
|
||||
"🔍 [렌더링] dataToDisplay 개수:",
|
||||
dataToDisplay.length,
|
||||
"leftGroupSumConfig:",
|
||||
leftGroupSumConfig,
|
||||
);
|
||||
|
||||
// 검색 필터링 (클라이언트 사이드)
|
||||
const filteredLeftData = leftSearchQuery
|
||||
? leftData.filter((item) => {
|
||||
? dataToDisplay.filter((item) => {
|
||||
const searchLower = leftSearchQuery.toLowerCase();
|
||||
return Object.entries(item).some(([key, value]) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
});
|
||||
})
|
||||
: leftData;
|
||||
: dataToDisplay;
|
||||
|
||||
// 재귀 렌더링 함수
|
||||
const renderTreeItem = (item: any, index: number): React.ReactNode => {
|
||||
@@ -2108,23 +2571,53 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
if (isTableMode) {
|
||||
// 테이블 모드 렌더링
|
||||
const displayColumns = componentConfig.rightPanel?.columns || [];
|
||||
const columnsToShow =
|
||||
displayColumns.length > 0
|
||||
? displayColumns.map((col) => ({
|
||||
...col,
|
||||
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||
format: col.format, // 🆕 포맷 설정 유지
|
||||
}))
|
||||
: Object.keys(filteredData[0] || {})
|
||||
.filter((key) => shouldShowField(key))
|
||||
.slice(0, 5)
|
||||
.map((key) => ({
|
||||
name: key,
|
||||
label: rightColumnLabels[key] || key,
|
||||
width: 150,
|
||||
align: "left" as const,
|
||||
format: undefined, // 🆕 기본값
|
||||
}));
|
||||
|
||||
// 🆕 그룹 합산 모드일 때: 복합키 컬럼을 우선 표시
|
||||
const relationKeys = componentConfig.rightPanel?.relation?.keys || [];
|
||||
const keyColumns = relationKeys.map((k: any) => k.leftColumn).filter(Boolean);
|
||||
const isGroupedMode = selectedLeftItem?._originalItems?.length > 0;
|
||||
|
||||
let columnsToShow: any[] = [];
|
||||
|
||||
if (displayColumns.length > 0) {
|
||||
// 설정된 컬럼 사용
|
||||
columnsToShow = displayColumns.map((col) => ({
|
||||
...col,
|
||||
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||
format: col.format,
|
||||
}));
|
||||
|
||||
// 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가
|
||||
if (isGroupedMode && keyColumns.length > 0) {
|
||||
const existingColNames = columnsToShow.map((c) => c.name);
|
||||
const missingKeyColumns = keyColumns.filter((k: string) => !existingColNames.includes(k));
|
||||
|
||||
if (missingKeyColumns.length > 0) {
|
||||
const keyColsToAdd = missingKeyColumns.map((colName: string) => ({
|
||||
name: colName,
|
||||
label: rightColumnLabels[colName] || colName,
|
||||
width: 120,
|
||||
align: "left" as const,
|
||||
format: undefined,
|
||||
_isKeyColumn: true, // 구분용 플래그
|
||||
}));
|
||||
columnsToShow = [...keyColsToAdd, ...columnsToShow];
|
||||
console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 기본 컬럼 자동 생성
|
||||
columnsToShow = Object.keys(filteredData[0] || {})
|
||||
.filter((key) => shouldShowField(key))
|
||||
.slice(0, 5)
|
||||
.map((key) => ({
|
||||
name: key,
|
||||
label: rightColumnLabels[key] || key,
|
||||
width: 150,
|
||||
align: "left" as const,
|
||||
format: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -2150,11 +2643,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
{!isDesignMode && (
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
작업
|
||||
</th>
|
||||
)}
|
||||
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */}
|
||||
{!isDesignMode &&
|
||||
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
작업
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
@@ -2169,43 +2665,51 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{formatCellValue(col.name, item[col.name], rightCategoryMappings, col.format)}
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{!isDesignMode && (
|
||||
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
||||
<div className="flex justify-end gap-1">
|
||||
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||
<Button
|
||||
variant={
|
||||
componentConfig.rightPanel?.editButton?.buttonVariant || "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("right", item);
|
||||
}}
|
||||
className="h-7"
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||
</Button>
|
||||
)}
|
||||
{(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick("right", item);
|
||||
}}
|
||||
className="rounded p-1 transition-colors hover:bg-red-100"
|
||||
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */}
|
||||
{!isDesignMode &&
|
||||
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
||||
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
||||
<div className="flex justify-end gap-1">
|
||||
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||
<Button
|
||||
variant={
|
||||
componentConfig.rightPanel?.editButton?.buttonVariant || "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("right", item);
|
||||
}}
|
||||
className="h-7"
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||
</Button>
|
||||
)}
|
||||
{(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick("right", item);
|
||||
}}
|
||||
className="rounded p-1 transition-colors hover:bg-red-100"
|
||||
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -2240,78 +2744,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
firstValues = rightColumns
|
||||
.slice(0, summaryCount)
|
||||
.map((col) => {
|
||||
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name)
|
||||
let value = item[col.name];
|
||||
if (value === undefined && col.name.includes(".")) {
|
||||
const columnName = col.name.split(".").pop();
|
||||
// 1차: 컬럼명 그대로 (예: item_number)
|
||||
value = item[columnName || ""];
|
||||
// 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인
|
||||
if (value === undefined) {
|
||||
const parts = col.name.split(".");
|
||||
if (parts.length === 2) {
|
||||
const refTable = parts[0]; // item_info
|
||||
const refColumn = parts[1]; // item_number 또는 item_name
|
||||
// FK 컬럼명 추론: item_info → item_id
|
||||
const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
|
||||
|
||||
// 백엔드에서 반환하는 별칭 패턴:
|
||||
// 1) item_id_name (기본 referenceColumn)
|
||||
// 2) item_id_item_name (추가 컬럼)
|
||||
if (
|
||||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
|
||||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
|
||||
) {
|
||||
// 기본 참조 컬럼 (item_number, customer_code 등)
|
||||
const aliasKey = fkColumn + "_name";
|
||||
value = item[aliasKey];
|
||||
} else {
|
||||
// 추가 컬럼 (item_name, customer_name 등)
|
||||
const aliasKey = `${fkColumn}_${refColumn}`;
|
||||
value = item[aliasKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용)
|
||||
const value = getEntityJoinValue(item, col.name);
|
||||
return [col.name, value, col.label] as [string, any, string];
|
||||
})
|
||||
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
||||
|
||||
allValues = rightColumns
|
||||
.map((col) => {
|
||||
// 🆕 엔티티 조인 컬럼 처리
|
||||
let value = item[col.name];
|
||||
if (value === undefined && col.name.includes(".")) {
|
||||
const columnName = col.name.split(".").pop();
|
||||
// 1차: 컬럼명 그대로
|
||||
value = item[columnName || ""];
|
||||
// 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인
|
||||
if (value === undefined) {
|
||||
const parts = col.name.split(".");
|
||||
if (parts.length === 2) {
|
||||
const refTable = parts[0]; // item_info
|
||||
const refColumn = parts[1]; // item_number 또는 item_name
|
||||
// FK 컬럼명 추론: item_info → item_id
|
||||
const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
|
||||
|
||||
// 백엔드에서 반환하는 별칭 패턴:
|
||||
// 1) item_id_name (기본 referenceColumn)
|
||||
// 2) item_id_item_name (추가 컬럼)
|
||||
if (
|
||||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
|
||||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
|
||||
) {
|
||||
// 기본 참조 컬럼
|
||||
const aliasKey = fkColumn + "_name";
|
||||
value = item[aliasKey];
|
||||
} else {
|
||||
// 추가 컬럼
|
||||
const aliasKey = `${fkColumn}_${refColumn}`;
|
||||
value = item[aliasKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용)
|
||||
const value = getEntityJoinValue(item, col.name);
|
||||
return [col.name, value, col.label] as [string, any, string];
|
||||
})
|
||||
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
||||
|
||||
@@ -58,3 +58,13 @@ export type { SplitPanelLayoutConfig } from "./types";
|
||||
// 컴포넌트 내보내기
|
||||
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
||||
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";
|
||||
|
||||
// Resize Context 내보내기 (버튼 등 외부 컴포넌트에서 분할 패널 드래그 리사이즈 상태 활용)
|
||||
export {
|
||||
SplitPanelProvider,
|
||||
useSplitPanel,
|
||||
useAdjustedPosition,
|
||||
useSplitPanelAwarePosition,
|
||||
useAdjustedComponentPosition,
|
||||
} from "./SplitPanelContext";
|
||||
export type { SplitPanelResizeContextValue, SplitPanelInfo } from "./SplitPanelContext";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -224,6 +224,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||
isInModal: _isInModal,
|
||||
isPreview: _isPreview,
|
||||
originalData: _originalData,
|
||||
_originalData: __originalData,
|
||||
_initialData: __initialData,
|
||||
_groupedData: __groupedData,
|
||||
allComponents: _allComponents,
|
||||
selectedRows: _selectedRows,
|
||||
selectedRowsData: _selectedRowsData,
|
||||
|
||||
@@ -126,11 +126,13 @@ export function UniversalFormModalComponent({
|
||||
initialData: propInitialData,
|
||||
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
||||
_initialData,
|
||||
_originalData,
|
||||
_groupedData,
|
||||
onSave,
|
||||
onCancel,
|
||||
onChange,
|
||||
...restProps // 나머지 props는 DOM에 전달하지 않음
|
||||
}: UniversalFormModalComponentProps & { _initialData?: any }) {
|
||||
}: UniversalFormModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
|
||||
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
|
||||
const initialData = propInitialData || _initialData;
|
||||
// 설정 병합
|
||||
@@ -198,29 +200,49 @@ export function UniversalFormModalComponent({
|
||||
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
|
||||
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
|
||||
const hasInitialized = useRef(false);
|
||||
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
|
||||
const lastInitializedId = useRef<string | undefined>(undefined);
|
||||
|
||||
// 초기화 - 최초 마운트 시에만 실행
|
||||
// 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
|
||||
useEffect(() => {
|
||||
// 이미 초기화되었으면 스킵
|
||||
if (hasInitialized.current) {
|
||||
// initialData에서 ID 값 추출 (id, ID, objid 등)
|
||||
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
|
||||
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
|
||||
|
||||
// 이미 초기화되었고, ID가 동일하면 스킵
|
||||
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
|
||||
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
|
||||
console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
|
||||
prevId: lastInitializedId.current,
|
||||
newId: currentIdString,
|
||||
initialData: initialData,
|
||||
});
|
||||
// 채번 플래그 초기화 (새 항목이므로)
|
||||
numberingGeneratedRef.current = false;
|
||||
isGeneratingRef.current = false;
|
||||
}
|
||||
|
||||
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
|
||||
if (initialData && Object.keys(initialData).length > 0) {
|
||||
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
|
||||
lastInitializedId.current = currentIdString;
|
||||
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
|
||||
}
|
||||
|
||||
hasInitialized.current = true;
|
||||
initializeForm();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
|
||||
}, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
|
||||
|
||||
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
|
||||
|
||||
console.log('[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)');
|
||||
console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
|
||||
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
@@ -228,7 +250,7 @@ export function UniversalFormModalComponent({
|
||||
// 컴포넌트 unmount 시 채번 플래그 초기화
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('[채번] 컴포넌트 unmount - 플래그 초기화');
|
||||
console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
|
||||
numberingGeneratedRef.current = false;
|
||||
isGeneratingRef.current = false;
|
||||
};
|
||||
@@ -239,7 +261,7 @@ export function UniversalFormModalComponent({
|
||||
useEffect(() => {
|
||||
const handleBeforeFormSave = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
||||
|
||||
|
||||
// 설정에 정의된 필드 columnName 목록 수집
|
||||
const configuredFields = new Set<string>();
|
||||
config.sections.forEach((section) => {
|
||||
@@ -249,10 +271,10 @@ export function UniversalFormModalComponent({
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
||||
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
||||
|
||||
|
||||
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
||||
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
||||
// (UniversalFormModal이 해당 필드의 주인이므로)
|
||||
@@ -260,7 +282,7 @@ export function UniversalFormModalComponent({
|
||||
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
||||
const isConfiguredField = configuredFields.has(key);
|
||||
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
||||
|
||||
|
||||
if (isConfiguredField || isNumberingRuleId) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
event.detail.formData[key] = value;
|
||||
@@ -268,7 +290,7 @@ export function UniversalFormModalComponent({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 반복 섹션 데이터도 병합 (필요한 경우)
|
||||
if (Object.keys(repeatSections).length > 0) {
|
||||
for (const [sectionId, items] of Object.entries(repeatSections)) {
|
||||
@@ -278,9 +300,9 @@ export function UniversalFormModalComponent({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||
};
|
||||
@@ -314,11 +336,18 @@ export function UniversalFormModalComponent({
|
||||
|
||||
// 폼 초기화
|
||||
const initializeForm = useCallback(async () => {
|
||||
console.log('[initializeForm] 시작');
|
||||
|
||||
console.log("[initializeForm] 시작");
|
||||
|
||||
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
|
||||
const effectiveInitialData = capturedInitialData.current || initialData;
|
||||
|
||||
console.log("[initializeForm] 초기 데이터:", {
|
||||
capturedInitialData: capturedInitialData.current,
|
||||
initialData: initialData,
|
||||
effectiveInitialData: effectiveInitialData,
|
||||
hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
|
||||
});
|
||||
|
||||
const newFormData: FormDataState = {};
|
||||
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
||||
const newCollapsed = new Set<string>();
|
||||
@@ -366,9 +395,9 @@ export function UniversalFormModalComponent({
|
||||
setOriginalData(effectiveInitialData || {});
|
||||
|
||||
// 채번규칙 자동 생성
|
||||
console.log('[initializeForm] generateNumberingValues 호출');
|
||||
console.log("[initializeForm] generateNumberingValues 호출");
|
||||
await generateNumberingValues(newFormData);
|
||||
console.log('[initializeForm] 완료');
|
||||
console.log("[initializeForm] 완료");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
||||
|
||||
@@ -389,23 +418,23 @@ export function UniversalFormModalComponent({
|
||||
// 채번규칙 자동 생성 (중복 호출 방지)
|
||||
const numberingGeneratedRef = useRef(false);
|
||||
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
|
||||
|
||||
|
||||
const generateNumberingValues = useCallback(
|
||||
async (currentFormData: FormDataState) => {
|
||||
// 이미 생성되었거나 진행 중이면 스킵
|
||||
if (numberingGeneratedRef.current) {
|
||||
console.log('[채번] 이미 생성됨 - 스킵');
|
||||
console.log("[채번] 이미 생성됨 - 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isGeneratingRef.current) {
|
||||
console.log('[채번] 생성 진행 중 - 스킵');
|
||||
console.log("[채번] 생성 진행 중 - 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
isGeneratingRef.current = true; // 진행 중 표시
|
||||
console.log('[채번] 미리보기 생성 시작');
|
||||
|
||||
console.log("[채번] 생성 시작");
|
||||
|
||||
const updatedData = { ...currentFormData };
|
||||
let hasChanges = false;
|
||||
|
||||
@@ -425,21 +454,23 @@ export function UniversalFormModalComponent({
|
||||
const response = await previewNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
updatedData[field.columnName] = response.data.generatedCode;
|
||||
|
||||
|
||||
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
|
||||
const ruleIdKey = `${field.columnName}_numberingRuleId`;
|
||||
updatedData[ruleIdKey] = field.numberingRule.ruleId;
|
||||
|
||||
|
||||
hasChanges = true;
|
||||
numberingGeneratedRef.current = true; // 생성 완료 표시
|
||||
console.log(`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`);
|
||||
console.log(
|
||||
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
|
||||
);
|
||||
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
|
||||
|
||||
|
||||
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
|
||||
if (onChange) {
|
||||
onChange({
|
||||
onChange({
|
||||
...updatedData,
|
||||
[ruleIdKey]: field.numberingRule.ruleId
|
||||
[ruleIdKey]: field.numberingRule.ruleId,
|
||||
});
|
||||
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
|
||||
}
|
||||
@@ -452,7 +483,7 @@ export function UniversalFormModalComponent({
|
||||
}
|
||||
|
||||
isGeneratingRef.current = false; // 진행 완료
|
||||
|
||||
|
||||
if (hasChanges) {
|
||||
setFormData(updatedData);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user