Compare commits

...

3 Commits

4 changed files with 1806 additions and 1244 deletions

View File

@ -1646,7 +1646,18 @@ export class NodeFlowExecutionService {
// WHERE 조건 생성 // WHERE 조건 생성
const whereClauses: string[] = []; const whereClauses: string[] = [];
whereConditions?.forEach((condition: any) => { whereConditions?.forEach((condition: any) => {
const condValue = data[condition.field]; // 🔥 수정: sourceField가 있으면 소스 데이터에서 값을 가져옴
let condValue: any;
if (condition.sourceField) {
condValue = data[condition.sourceField];
} else if (
condition.staticValue !== undefined &&
condition.staticValue !== ""
) {
condValue = condition.staticValue;
} else {
condValue = data[condition.field];
}
if (condition.operator === "IS NULL") { if (condition.operator === "IS NULL") {
whereClauses.push(`${condition.field} IS NULL`); whereClauses.push(`${condition.field} IS NULL`);
@ -1987,7 +1998,18 @@ export class NodeFlowExecutionService {
// WHERE 조건 생성 // WHERE 조건 생성
whereConditions?.forEach((condition: any) => { whereConditions?.forEach((condition: any) => {
const condValue = data[condition.field]; // 🔥 수정: sourceField가 있으면 소스 데이터에서 값을 가져옴
let condValue: any;
if (condition.sourceField) {
condValue = data[condition.sourceField];
} else if (
condition.staticValue !== undefined &&
condition.staticValue !== ""
) {
condValue = condition.staticValue;
} else {
condValue = data[condition.field];
}
if (condition.operator === "IS NULL") { if (condition.operator === "IS NULL") {
whereClauses.push(`${condition.field} IS NULL`); whereClauses.push(`${condition.field} IS NULL`);
@ -2889,7 +2911,26 @@ export class NodeFlowExecutionService {
const values: any[] = []; const values: any[] = [];
const clauses = conditions.map((condition, index) => { const clauses = conditions.map((condition, index) => {
const value = data ? data[condition.field] : condition.value; // 🔥 수정: sourceField가 있으면 소스 데이터에서 값을 가져오고,
// 없으면 staticValue 또는 기존 field 사용
let value: any;
if (data) {
if (condition.sourceField) {
// sourceField가 있으면 소스 데이터에서 해당 필드의 값을 가져옴
value = data[condition.sourceField];
} else if (
condition.staticValue !== undefined &&
condition.staticValue !== ""
) {
// staticValue가 있으면 사용
value = condition.staticValue;
} else {
// 둘 다 없으면 기존 방식 (field로 값 조회)
value = data[condition.field];
}
} else {
value = condition.value;
}
values.push(value); values.push(value);
// 연산자를 SQL 문법으로 변환 // 연산자를 SQL 문법으로 변환

View File

@ -60,15 +60,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const resizable = componentConfig.resizable ?? true; const resizable = componentConfig.resizable ?? true;
const minLeftWidth = componentConfig.minLeftWidth || 200; const minLeftWidth = componentConfig.minLeftWidth || 200;
const minRightWidth = componentConfig.minRightWidth || 300; const minRightWidth = componentConfig.minRightWidth || 300;
// 필드 표시 유틸리티 (하드코딩 제거, 동적으로 작동) // 필드 표시 유틸리티 (하드코딩 제거, 동적으로 작동)
const shouldShowField = (fieldName: string): boolean => { const shouldShowField = (fieldName: string): boolean => {
const lower = fieldName.toLowerCase(); const lower = fieldName.toLowerCase();
// 기본 제외: id, 비밀번호, 토큰, 회사코드 // 기본 제외: id, 비밀번호, 토큰, 회사코드
if (lower === "id" || lower === "company_code" || lower === "company_name") return false; if (lower === "id" || lower === "company_code" || lower === "company_name") return false;
if (lower.includes("password") || lower.includes("token")) return false; if (lower.includes("password") || lower.includes("token")) return false;
// 나머지는 모두 표시! // 나머지는 모두 표시!
return true; return true;
}; };
@ -284,26 +284,102 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})); }));
}, [leftData, leftGrouping]); }, [leftData, leftGrouping]);
// 셀 값 포맷팅 함수 (카테고리 타입 처리) // 날짜 포맷팅 헬퍼 함수
const formatDateValue = useCallback((value: any, dateFormat: string): string => {
if (!value) return "-";
const date = new Date(value);
if (isNaN(date.getTime())) return String(value);
if (dateFormat === "relative") {
// 상대 시간 (예: 3일 전, 2시간 전)
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
const diffMonth = Math.floor(diffDay / 30);
const diffYear = Math.floor(diffMonth / 12);
if (diffYear > 0) return `${diffYear}년 전`;
if (diffMonth > 0) return `${diffMonth}개월 전`;
if (diffDay > 0) return `${diffDay}일 전`;
if (diffHour > 0) return `${diffHour}시간 전`;
if (diffMin > 0) return `${diffMin}분 전`;
return "방금 전";
}
// 포맷 문자열 치환
return dateFormat
.replace("YYYY", String(date.getFullYear()))
.replace("MM", String(date.getMonth() + 1).padStart(2, "0"))
.replace("DD", String(date.getDate()).padStart(2, "0"))
.replace("HH", String(date.getHours()).padStart(2, "0"))
.replace("mm", String(date.getMinutes()).padStart(2, "0"))
.replace("ss", String(date.getSeconds()).padStart(2, "0"));
}, []);
// 숫자 포맷팅 헬퍼 함수
const formatNumberValue = useCallback((value: any, format: any): string => {
if (value === null || value === undefined || value === "") return "-";
const num = typeof value === "number" ? value : parseFloat(String(value));
if (isNaN(num)) return String(value);
const options: Intl.NumberFormatOptions = {
minimumFractionDigits: format?.decimalPlaces ?? 0,
maximumFractionDigits: format?.decimalPlaces ?? 10,
useGrouping: format?.thousandSeparator ?? false,
};
let result = num.toLocaleString("ko-KR", options);
if (format?.prefix) result = format.prefix + result;
if (format?.suffix) result = result + format.suffix;
return result;
}, []);
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
const formatCellValue = useCallback( const formatCellValue = useCallback(
( (
columnName: string, columnName: string,
value: any, value: any,
categoryMappings: Record<string, Record<string, { label: string; color?: string }>>, categoryMappings: Record<string, Record<string, { label: string; color?: string }>>,
format?: {
type?: "number" | "currency" | "date" | "text";
thousandSeparator?: boolean;
decimalPlaces?: number;
prefix?: string;
suffix?: string;
dateFormat?: string;
},
) => { ) => {
if (value === null || value === undefined) return "-"; if (value === null || value === undefined) return "-";
// 🆕 날짜 포맷 적용
if (format?.type === "date" || format?.dateFormat) {
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
}
// 🆕 숫자 포맷 적용
if (
format?.type === "number" ||
format?.type === "currency" ||
format?.thousandSeparator ||
format?.decimalPlaces !== undefined
) {
return formatNumberValue(value, format);
}
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도) // 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
// 1. 전체 컬럼명 (예: "item_info.material") // 1. 전체 컬럼명 (예: "item_info.material")
// 2. 컬럼명만 (예: "material") // 2. 컬럼명만 (예: "material")
let mapping = categoryMappings[columnName]; let mapping = categoryMappings[columnName];
if (!mapping && columnName.includes(".")) { if (!mapping && columnName.includes(".")) {
// 조인된 컬럼의 경우 컬럼명만으로 다시 시도 // 조인된 컬럼의 경우 컬럼명만으로 다시 시도
const simpleColumnName = columnName.split(".").pop() || columnName; const simpleColumnName = columnName.split(".").pop() || columnName;
mapping = categoryMappings[simpleColumnName]; mapping = categoryMappings[simpleColumnName];
} }
if (mapping && mapping[String(value)]) { if (mapping && mapping[String(value)]) {
const categoryData = mapping[String(value)]; const categoryData = mapping[String(value)];
const displayLabel = categoryData.label || String(value); const displayLabel = categoryData.label || String(value);
@ -323,10 +399,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
); );
} }
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
return formatDateValue(value, "YYYY-MM-DD");
}
// 🆕 자동 숫자 감지 (숫자 또는 숫자 문자열) - 소수점 있으면 정수로 변환
if (typeof value === "number") {
// 숫자인 경우 정수로 표시 (소수점 제거)
return Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100);
}
if (typeof value === "string" && /^-?\d+\.?\d*$/.test(value.trim())) {
// 숫자 문자열인 경우 (예: "5.00" → "5")
const num = parseFloat(value);
if (!isNaN(num)) {
return Number.isInteger(num) ? String(num) : String(Math.round(num * 100) / 100);
}
}
// 일반 값 // 일반 값
return String(value); return String(value);
}, },
[], [formatDateValue, formatNumberValue],
); );
// 좌측 데이터 로드 // 좌측 데이터 로드
@ -392,7 +486,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (relationshipType === "detail") { if (relationshipType === "detail") {
// 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화) // 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화)
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0]; const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
// 🆕 엔티티 조인 API 사용 // 🆕 엔티티 조인 API 사용
const { entityJoinApi } = await import("@/lib/api/entityJoin"); const { entityJoinApi } = await import("@/lib/api/entityJoin");
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
@ -400,29 +494,81 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
enableEntityJoin: true, // 엔티티 조인 활성화 enableEntityJoin: true, // 엔티티 조인 활성화
size: 1, size: 1,
}); });
const detail = result.items && result.items.length > 0 ? result.items[0] : null; const detail = result.items && result.items.length > 0 ? result.items[0] : null;
setRightData(detail); setRightData(detail);
} else if (relationshipType === "join") { } else if (relationshipType === "join") {
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개) // 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const keys = componentConfig.rightPanel?.relation?.keys;
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
const leftTable = componentConfig.leftPanel?.tableName; const leftTable = componentConfig.leftPanel?.tableName;
if (leftColumn && rightColumn && leftTable) { // 🆕 복합키 지원
const leftValue = leftItem[leftColumn]; if (keys && keys.length > 0 && leftTable) {
const joinedData = await dataApi.getJoinedData( // 복합키: 여러 조건으로 필터링
leftTable, const { entityJoinApi } = await import("@/lib/api/entityJoin");
rightTableName,
leftColumn, // 복합키 조건 생성
rightColumn, const searchConditions: Record<string, any> = {};
leftValue, keys.forEach((key) => {
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달 if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
true, // 🆕 Entity 조인 활성화 searchConditions[key.rightColumn] = leftItem[key.leftColumn];
componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등) }
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 });
);
setRightData(joinedData || []); // 모든 관련 레코드 (배열) console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
// 엔티티 조인 API로 데이터 조회
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
});
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
// 추가 dataFilter 적용
let filteredData = result.data || [];
const dataFilter = componentConfig.rightPanel?.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
filteredData = filteredData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => {
const value = item[cond.column];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
return value === condValue;
case "notEquals":
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
default:
return true;
}
});
});
}
setRightData(filteredData);
} else {
// 단일키 (하위 호환성)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
if (leftColumn && rightColumn && leftTable) {
const leftValue = leftItem[leftColumn];
const joinedData = await dataApi.getJoinedData(
leftTable,
rightTableName,
leftColumn,
rightColumn,
leftValue,
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
true, // 🆕 Entity 조인 활성화
componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등)
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
);
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
}
} }
} }
} catch (error) { } catch (error) {
@ -711,7 +857,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출 // 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출
const rightColumns = componentConfig.rightPanel?.columns || []; const rightColumns = componentConfig.rightPanel?.columns || [];
const tablesToLoad = new Set<string>([rightTableName]); const tablesToLoad = new Set<string>([rightTableName]);
// 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info") // 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info")
rightColumns.forEach((col: any) => { rightColumns.forEach((col: any) => {
const colName = col.name || col.columnName; const colName = col.name || col.columnName;
@ -744,15 +890,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
color: item.color, color: item.color,
}; };
}); });
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장 // 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`; const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
mappings[mappingKey] = valueMap; mappings[mappingKey] = valueMap;
// 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블) // 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
// 기존 매핑이 있으면 병합, 없으면 새로 생성 // 기존 매핑이 있으면 병합, 없으면 새로 생성
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap }; mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap); console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap);
console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]); console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]);
} }
@ -818,15 +964,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 🆕 우측 패널 수정 버튼 설정 확인 // 🆕 우측 패널 수정 버튼 설정 확인
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") { if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId; const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
if (modalScreenId) { if (modalScreenId) {
// 커스텀 모달 화면 열기 // 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || ""; const rightTableName = componentConfig.rightPanel?.tableName || "";
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) // Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
let primaryKeyName = "id"; let primaryKeyName = "id";
let primaryKeyValue: any; let primaryKeyValue: any;
if (item.id !== undefined && item.id !== null) { if (item.id !== undefined && item.id !== null) {
primaryKeyName = "id"; primaryKeyName = "id";
primaryKeyValue = item.id; primaryKeyValue = item.id;
@ -839,29 +985,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
primaryKeyName = firstKey; primaryKeyName = firstKey;
primaryKeyValue = item[firstKey]; primaryKeyValue = item[firstKey];
} }
console.log(`✅ 수정 모달 열기:`, { console.log("✅ 수정 모달 열기:", {
tableName: rightTableName, tableName: rightTableName,
primaryKeyName, primaryKeyName,
primaryKeyValue, primaryKeyValue,
screenId: modalScreenId, screenId: modalScreenId,
fullItem: item, fullItem: item,
}); });
// modalDataStore에도 저장 (호환성 유지) // modalDataStore에도 저장 (호환성 유지)
import("@/stores/modalDataStore").then(({ useModalDataStore }) => { import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(rightTableName, [item]); useModalDataStore.getState().setData(rightTableName, [item]);
}); });
// 🆕 groupByColumns 추출 // 🆕 groupByColumns 추출
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
groupByColumns, groupByColumns,
editButtonConfig: componentConfig.rightPanel?.editButton, editButtonConfig: componentConfig.rightPanel?.editButton,
hasGroupByColumns: groupByColumns.length > 0, hasGroupByColumns: groupByColumns.length > 0,
}); });
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달)
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("openScreenModal", { new CustomEvent("openScreenModal", {
@ -878,18 +1024,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}, },
}), }),
); );
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", {
screenId: modalScreenId, screenId: modalScreenId,
editId: primaryKeyValue, editId: primaryKeyValue,
tableName: rightTableName, tableName: rightTableName,
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
}); });
return; return;
} }
} }
// 기존 자동 편집 모드 (인라인 편집 모달) // 기존 자동 편집 모드 (인라인 편집 모달)
setEditModalPanel(panel); setEditModalPanel(panel);
setEditModalItem(item); setEditModalItem(item);
@ -1026,7 +1172,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
try { try {
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
// 🔍 중복 제거 설정 디버깅 // 🔍 중복 제거 설정 디버깅
console.log("🔍 중복 제거 디버깅:", { console.log("🔍 중복 제거 디버깅:", {
panel: deleteModalPanel, panel: deleteModalPanel,
@ -1041,25 +1187,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) { if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) {
const deduplication = componentConfig.rightPanel.dataFilter.deduplication; const deduplication = componentConfig.rightPanel.dataFilter.deduplication;
const groupByColumn = deduplication.groupByColumn; const groupByColumn = deduplication.groupByColumn;
if (groupByColumn && deleteModalItem[groupByColumn]) { if (groupByColumn && deleteModalItem[groupByColumn]) {
const groupValue = deleteModalItem[groupByColumn]; const groupValue = deleteModalItem[groupByColumn];
console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`);
// groupByColumn 값으로 필터링하여 삭제 // groupByColumn 값으로 필터링하여 삭제
const filterConditions: Record<string, any> = { const filterConditions: Record<string, any> = {
[groupByColumn]: groupValue, [groupByColumn]: groupValue,
}; };
// 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등)
if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") {
const leftColumn = componentConfig.rightPanel.join.leftColumn; const leftColumn = componentConfig.rightPanel.join.leftColumn;
const rightColumn = componentConfig.rightPanel.join.rightColumn; const rightColumn = componentConfig.rightPanel.join.rightColumn;
filterConditions[rightColumn] = selectedLeftItem[leftColumn]; filterConditions[rightColumn] = selectedLeftItem[leftColumn];
} }
console.log("🗑️ 그룹 삭제 조건:", filterConditions); console.log("🗑️ 그룹 삭제 조건:", filterConditions);
// 그룹 삭제 API 호출 // 그룹 삭제 API 호출
result = await dataApi.deleteGroupRecords(tableName, filterConditions); result = await dataApi.deleteGroupRecords(tableName, filterConditions);
} else { } else {
@ -1527,6 +1673,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
leftColumnLabels[colName] || (typeof col === "object" ? col.label : null) || colName, leftColumnLabels[colName] || (typeof col === "object" ? col.label : null) || colName,
width: typeof col === "object" ? col.width : 150, width: typeof col === "object" ? col.width : 150,
align: (typeof col === "object" ? col.align : "left") as "left" | "center" | "right", align: (typeof col === "object" ? col.align : "left") as "left" | "center" | "right",
format: typeof col === "object" ? col.format : undefined, // 🆕 포맷 설정 포함
}; };
}) })
: Object.keys(filteredData[0] || {}) : Object.keys(filteredData[0] || {})
@ -1537,6 +1684,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
label: leftColumnLabels[key] || key, label: leftColumnLabels[key] || key,
width: 150, width: 150,
align: "left" as const, align: "left" as const,
format: undefined, // 🆕 기본값
})); }));
// 🔧 그룹화된 데이터 렌더링 // 🔧 그룹화된 데이터 렌더링
@ -1587,7 +1735,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900" className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
style={{ textAlign: col.align || "left" }} style={{ textAlign: col.align || "left" }}
> >
{formatCellValue(col.name, item[col.name], leftCategoryMappings)} {formatCellValue(
col.name,
item[col.name],
leftCategoryMappings,
col.format,
)}
</td> </td>
))} ))}
</tr> </tr>
@ -1643,7 +1796,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900" className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
style={{ textAlign: col.align || "left" }} style={{ textAlign: col.align || "left" }}
> >
{formatCellValue(col.name, item[col.name], leftCategoryMappings)} {formatCellValue(col.name, item[col.name], leftCategoryMappings, col.format)}
</td> </td>
))} ))}
</tr> </tr>
@ -1747,7 +1900,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} else { } else {
// 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시 // 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시
const keys = Object.keys(item).filter( const keys = Object.keys(item).filter(
(k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k) (k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k),
); );
displayFields = keys.slice(0, 2).map((key) => ({ displayFields = keys.slice(0, 2).map((key) => ({
label: leftColumnLabels[key] || key, label: leftColumnLabels[key] || key,
@ -1960,6 +2113,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
? displayColumns.map((col) => ({ ? displayColumns.map((col) => ({
...col, ...col,
label: rightColumnLabels[col.name] || col.label || col.name, label: rightColumnLabels[col.name] || col.label || col.name,
format: col.format, // 🆕 포맷 설정 유지
})) }))
: Object.keys(filteredData[0] || {}) : Object.keys(filteredData[0] || {})
.filter((key) => shouldShowField(key)) .filter((key) => shouldShowField(key))
@ -1969,6 +2123,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
label: rightColumnLabels[key] || key, label: rightColumnLabels[key] || key,
width: 150, width: 150,
align: "left" as const, align: "left" as const,
format: undefined, // 🆕 기본값
})); }));
return ( return (
@ -2014,7 +2169,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900" className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
style={{ textAlign: col.align || "left" }} style={{ textAlign: col.align || "left" }}
> >
{formatCellValue(col.name, item[col.name], rightCategoryMappings)} {formatCellValue(col.name, item[col.name], rightCategoryMappings, col.format)}
</td> </td>
))} ))}
{!isDesignMode && ( {!isDesignMode && (
@ -2022,7 +2177,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( {(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
<Button <Button
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"} variant={
componentConfig.rightPanel?.editButton?.buttonVariant || "outline"
}
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -2030,20 +2187,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}} }}
className="h-7" className="h-7"
> >
<Pencil className="h-3 w-3 mr-1" /> <Pencil className="mr-1 h-3 w-3" />
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"} {componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
</Button> </Button>
)} )}
<button {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
onClick={(e) => { <button
e.stopPropagation(); onClick={(e) => {
handleDeleteClick("right", item); e.stopPropagation();
}} handleDeleteClick("right", item);
className="rounded p-1 transition-colors hover:bg-red-100" }}
title="삭제" 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> <Trash2 className="h-4 w-4 text-red-600" />
</button>
)}
</div> </div>
</td> </td>
)} )}
@ -2083,26 +2242,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
.map((col) => { .map((col) => {
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name) // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name)
let value = item[col.name]; let value = item[col.name];
if (value === undefined && col.name.includes('.')) { if (value === undefined && col.name.includes(".")) {
const columnName = col.name.split('.').pop(); const columnName = col.name.split(".").pop();
// 1차: 컬럼명 그대로 (예: item_number) // 1차: 컬럼명 그대로 (예: item_number)
value = item[columnName || '']; value = item[columnName || ""];
// 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인 // 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인
if (value === undefined) { if (value === undefined) {
const parts = col.name.split('.'); const parts = col.name.split(".");
if (parts.length === 2) { if (parts.length === 2) {
const refTable = parts[0]; // item_info const refTable = parts[0]; // item_info
const refColumn = parts[1]; // item_number 또는 item_name const refColumn = parts[1]; // item_number 또는 item_name
// FK 컬럼명 추론: item_info → item_id // FK 컬럼명 추론: item_info → item_id
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id'; const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
// 백엔드에서 반환하는 별칭 패턴: // 백엔드에서 반환하는 별칭 패턴:
// 1) item_id_name (기본 referenceColumn) // 1) item_id_name (기본 referenceColumn)
// 2) item_id_item_name (추가 컬럼) // 2) item_id_item_name (추가 컬럼)
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' || if (
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') { refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
) {
// 기본 참조 컬럼 (item_number, customer_code 등) // 기본 참조 컬럼 (item_number, customer_code 등)
const aliasKey = fkColumn + '_name'; const aliasKey = fkColumn + "_name";
value = item[aliasKey]; value = item[aliasKey];
} else { } else {
// 추가 컬럼 (item_name, customer_name 등) // 추가 컬럼 (item_name, customer_name 등)
@ -2120,26 +2281,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
.map((col) => { .map((col) => {
// 🆕 엔티티 조인 컬럼 처리 // 🆕 엔티티 조인 컬럼 처리
let value = item[col.name]; let value = item[col.name];
if (value === undefined && col.name.includes('.')) { if (value === undefined && col.name.includes(".")) {
const columnName = col.name.split('.').pop(); const columnName = col.name.split(".").pop();
// 1차: 컬럼명 그대로 // 1차: 컬럼명 그대로
value = item[columnName || '']; value = item[columnName || ""];
// 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인 // 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인
if (value === undefined) { if (value === undefined) {
const parts = col.name.split('.'); const parts = col.name.split(".");
if (parts.length === 2) { if (parts.length === 2) {
const refTable = parts[0]; // item_info const refTable = parts[0]; // item_info
const refColumn = parts[1]; // item_number 또는 item_name const refColumn = parts[1]; // item_number 또는 item_name
// FK 컬럼명 추론: item_info → item_id // FK 컬럼명 추론: item_info → item_id
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id'; const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
// 백엔드에서 반환하는 별칭 패턴: // 백엔드에서 반환하는 별칭 패턴:
// 1) item_id_name (기본 referenceColumn) // 1) item_id_name (기본 referenceColumn)
// 2) item_id_item_name (추가 컬럼) // 2) item_id_item_name (추가 컬럼)
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' || if (
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') { refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
) {
// 기본 참조 컬럼 // 기본 참조 컬럼
const aliasKey = fkColumn + '_name'; const aliasKey = fkColumn + "_name";
value = item[aliasKey]; value = item[aliasKey];
} else { } else {
// 추가 컬럼 // 추가 컬럼
@ -2158,11 +2321,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
firstValues = Object.entries(item) firstValues = Object.entries(item)
.filter(([key]) => !key.toLowerCase().includes("id")) .filter(([key]) => !key.toLowerCase().includes("id"))
.slice(0, summaryCount) .slice(0, summaryCount)
.map(([key, value]) => [key, value, ''] as [string, any, string]); .map(([key, value]) => [key, value, ""] as [string, any, string]);
allValues = Object.entries(item) allValues = Object.entries(item)
.filter(([key, value]) => value !== null && value !== undefined && value !== "") .filter(([key, value]) => value !== null && value !== undefined && value !== "")
.map(([key, value]) => [key, value, ''] as [string, any, string]); .map(([key, value]) => [key, value, ""] as [string, any, string]);
} }
return ( return (
@ -2180,30 +2343,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{firstValues.map(([key, value, label], idx) => { {firstValues.map(([key, value, label], idx) => {
// 포맷 설정 및 볼드 설정 찾기 // 포맷 설정 및 볼드 설정 찾기
const colConfig = rightColumns?.find(c => c.name === key); const colConfig = rightColumns?.find((c) => c.name === key);
const format = colConfig?.format; const format = colConfig?.format;
const boldValue = colConfig?.bold ?? false; const boldValue = colConfig?.bold ?? false;
// 🆕 카테고리 매핑 적용 // 🆕 포맷 적용 (날짜/숫자/카테고리)
const formattedValue = formatCellValue(key, value, rightCategoryMappings); const displayValue = formatCellValue(key, value, rightCategoryMappings, format);
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
let displayValue: React.ReactNode = formattedValue;
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (!isNaN(numValue)) {
displayValue = numValue.toLocaleString('ko-KR', {
minimumFractionDigits: format.decimalPlaces ?? 0,
maximumFractionDigits: format.decimalPlaces ?? 10,
useGrouping: format.thousandSeparator ?? false,
});
if (format.prefix) displayValue = format.prefix + displayValue;
if (format.suffix) displayValue = displayValue + format.suffix;
}
}
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true; const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
return ( return (
<div key={key} className="flex items-baseline gap-1"> <div key={key} className="flex items-baseline gap-1">
{showLabel && ( {showLabel && (
@ -2211,8 +2359,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{label || getColumnLabel(key)}: {label || getColumnLabel(key)}:
</span> </span>
)} )}
<span <span
className={`text-foreground text-sm ${boldValue ? 'font-semibold' : ''}`} className={`text-foreground text-sm ${boldValue ? "font-semibold" : ""}`}
> >
{displayValue} {displayValue}
</span> </span>
@ -2233,19 +2381,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}} }}
className="h-7" className="h-7"
> >
<Pencil className="h-3 w-3 mr-1" /> <Pencil className="mr-1 h-3 w-3" />
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"} {componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
</Button> </Button>
)} )}
{/* 삭제 버튼 */} {/* 삭제 버튼 */}
{!isDesignMode && ( {!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteClick("right", item); handleDeleteClick("right", item);
}} }}
className="rounded p-1 transition-colors hover:bg-red-100" className="rounded p-1 transition-colors hover:bg-red-100"
title="삭제" title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
> >
<Trash2 className="h-4 w-4 text-red-600" /> <Trash2 className="h-4 w-4 text-red-600" />
</button> </button>
@ -2274,27 +2422,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tbody className="divide-border divide-y"> <tbody className="divide-border divide-y">
{allValues.map(([key, value, label]) => { {allValues.map(([key, value, label]) => {
// 포맷 설정 찾기 // 포맷 설정 찾기
const colConfig = rightColumns?.find(c => c.name === key); const colConfig = rightColumns?.find((c) => c.name === key);
const format = colConfig?.format; const format = colConfig?.format;
// 🆕 카테고리 매핑 적용 // 🆕 포맷 적용 (날짜/숫자/카테고리)
const formattedValue = formatCellValue(key, value, rightCategoryMappings); const displayValue = formatCellValue(key, value, rightCategoryMappings, format);
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
let displayValue: React.ReactNode = formattedValue;
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (!isNaN(numValue)) {
displayValue = numValue.toLocaleString('ko-KR', {
minimumFractionDigits: format.decimalPlaces ?? 0,
maximumFractionDigits: format.decimalPlaces ?? 10,
useGrouping: format.thousandSeparator ?? false,
});
if (format.prefix) displayValue = format.prefix + displayValue;
if (format.suffix) displayValue = displayValue + format.suffix;
}
}
return ( return (
<tr key={key} className="hover:bg-muted"> <tr key={key} className="hover:bg-muted">
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap"> <td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
@ -2310,8 +2443,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
)} )}
</div> </div>
); );
})} })}
</div> </div>
) : ( ) : (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-sm">
@ -2336,21 +2469,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("🔍 [디버깅] 상세 모드 표시 로직:"); console.log("🔍 [디버깅] 상세 모드 표시 로직:");
console.log(" 📋 rightData 전체:", rightData); console.log(" 📋 rightData 전체:", rightData);
console.log(" 📋 rightData keys:", Object.keys(rightData)); console.log(" 📋 rightData keys:", Object.keys(rightData));
console.log(" ⚙️ 설정된 컬럼:", rightColumns.map((c) => `${c.name} (${c.label})`)); console.log(
" ⚙️ 설정된 컬럼:",
rightColumns.map((c) => `${c.name} (${c.label})`),
);
// 설정된 컬럼만 표시 // 설정된 컬럼만 표시
displayEntries = rightColumns displayEntries = rightColumns
.map((col) => { .map((col) => {
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name)
let value = rightData[col.name]; let value = rightData[col.name];
console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`); console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`);
if (value === undefined && col.name.includes('.')) { if (value === undefined && col.name.includes(".")) {
const columnName = col.name.split('.').pop(); const columnName = col.name.split(".").pop();
value = rightData[columnName || '']; value = rightData[columnName || ""];
console.log(` → 변환 후 "${columnName}" 접근 = ${value}`); console.log(` → 변환 후 "${columnName}" 접근 = ${value}`);
} }
return [col.name, value, col.label] as [string, any, string]; return [col.name, value, col.label] as [string, any, string];
}) })
.filter(([key, value]) => { .filter(([key, value]) => {

View File

@ -104,10 +104,15 @@ export interface SplitPanelLayoutConfig {
// 좌측 선택 항목과의 관계 설정 // 좌측 선택 항목과의 관계 설정
relation?: { relation?: {
type: "join" | "detail"; // 관계 타입 type?: "join" | "detail"; // 관계 타입 (optional - 하위 호환성)
leftColumn?: string; // 좌측 테이블의 연결 컬럼 leftColumn?: string; // 좌측 테이블의 연결 컬럼 (단일키 - 하위 호환성)
rightColumn?: string; // 우측 테이블의 연결 컬럼 (join용) rightColumn?: string; // 우측 테이블의 연결 컬럼 (단일키 - 하위 호환성)
foreignKey?: string; // 우측 테이블의 외래키 컬럼명 foreignKey?: string; // 우측 테이블의 외래키 컬럼명
// 🆕 복합키 지원 (여러 컬럼으로 조인)
keys?: Array<{
leftColumn: string; // 좌측 테이블의 조인 컬럼
rightColumn: string; // 우측 테이블의 조인 컬럼
}>;
}; };
// 우측 패널 추가 시 중계 테이블 설정 (N:M 관계) // 우측 패널 추가 시 중계 테이블 설정 (N:M 관계)
@ -117,7 +122,7 @@ export interface SplitPanelLayoutConfig {
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지 leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지 targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
}; };
// 테이블 모드 설정 // 테이블 모드 설정
tableConfig?: { tableConfig?: {
showCheckbox?: boolean; // 체크박스 표시 여부 showCheckbox?: boolean; // 체크박스 표시 여부
@ -150,6 +155,14 @@ export interface SplitPanelLayoutConfig {
buttonVariant?: "default" | "outline" | "ghost"; // 버튼 스타일 (기본: "outline") buttonVariant?: "default" | "outline" | "ghost"; // 버튼 스타일 (기본: "outline")
groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"]) groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"])
}; };
// 🆕 삭제 버튼 설정
deleteButton?: {
enabled: boolean; // 삭제 버튼 표시 여부 (기본: true)
buttonLabel?: string; // 버튼 라벨 (기본: "삭제")
buttonVariant?: "default" | "outline" | "ghost" | "destructive"; // 버튼 스타일 (기본: "ghost")
confirmMessage?: string; // 삭제 확인 메시지
};
}; };
// 레이아웃 설정 // 레이아웃 설정