ERP-node/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx

3241 lines
139 KiB
TypeScript
Raw Normal View History

2025-10-15 17:25:38 +09:00
"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
2025-10-15 17:25:38 +09:00
import { ComponentRendererProps } from "../../types";
import { SplitPanelLayoutConfig } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Plus,
Search,
GripVertical,
Loader2,
ChevronDown,
ChevronUp,
Save,
ChevronRight,
Pencil,
Trash2,
} from "lucide-react";
2025-10-15 17:25:38 +09:00
import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin";
2025-10-15 17:25:38 +09:00
import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth";
import { useSplitPanel } from "./SplitPanelContext";
2025-10-15 17:25:38 +09:00
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
}
/**
* SplitPanelLayout
* -
*/
export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
2025-10-16 18:16:57 +09:00
isPreview = false,
2025-10-15 17:25:38 +09:00
onClick,
...props
}) => {
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
// 기본 설정값
const splitRatio = componentConfig.splitRatio || 30;
const resizable = componentConfig.resizable ?? true;
const minLeftWidth = componentConfig.minLeftWidth || 200;
const minRightWidth = componentConfig.minRightWidth || 300;
2025-12-11 11:37:40 +09:00
// 필드 표시 유틸리티 (하드코딩 제거, 동적으로 작동)
const shouldShowField = (fieldName: string): boolean => {
const lower = fieldName.toLowerCase();
2025-12-11 11:37:40 +09:00
// 기본 제외: id, 비밀번호, 토큰, 회사코드
if (lower === "id" || lower === "company_code" || lower === "company_name") return false;
if (lower.includes("password") || lower.includes("token")) return false;
2025-12-11 11:37:40 +09:00
// 나머지는 모두 표시!
return true;
};
2025-10-15 17:25:38 +09:00
// 🆕 엔티티 조인 컬럼명 변환 헬퍼
// "테이블명.컬럼명" 형식을 "원본컬럼_조인컬럼명" 형식으로 변환하여 데이터 접근
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];
}
2025-12-12 13:50:33 +09:00
// 🆕 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];
}
2025-12-12 13:50:33 +09:00
// 🆕 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[]>([]);
2025-10-15 17:25:38 +09:00
// 데이터 상태
const [leftData, setLeftData] = useState<any[]>([]);
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
2025-10-15 17:25:38 +09:00
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
2025-10-15 17:25:38 +09:00
const [leftSearchQuery, setLeftSearchQuery] = useState("");
const [rightSearchQuery, setRightSearchQuery] = useState("");
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
2025-11-07 15:21:44 +09:00
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({}); // 좌측 카테고리 매핑
const [rightCategoryMappings, setRightCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({}); // 우측 카테고리 매핑
2025-10-15 17:25:38 +09:00
const { toast } = useToast();
// 추가 모달 상태
const [showAddModal, setShowAddModal] = useState(false);
2025-11-07 15:21:44 +09:00
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
2025-11-07 16:02:01 +09:00
// 수정 모달 상태
const [showEditModal, setShowEditModal] = useState(false);
const [editModalPanel, setEditModalPanel] = useState<"left" | "right" | null>(null);
const [editModalItem, setEditModalItem] = useState<any>(null);
const [editModalFormData, setEditModalFormData] = useState<Record<string, any>>({});
2025-11-07 16:02:01 +09:00
// 삭제 확인 모달 상태
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null);
const [deleteModalItem, setDeleteModalItem] = useState<any>(null);
2025-10-15 17:25:38 +09:00
// 리사이저 드래그 상태
const [isDragging, setIsDragging] = useState(false);
const [leftWidth, setLeftWidth] = useState(splitRatio);
const containerRef = React.useRef<HTMLDivElement>(null);
2025-10-15 17:25:38 +09:00
// 🆕 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]);
2025-10-15 17:25:38 +09:00
// 컴포넌트 스타일
2025-11-05 16:18:00 +09:00
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
const getHeightValue = () => {
const height = component.style?.height;
if (!height) return "600px";
if (typeof height === "string") return height; // 이미 '540px' 형태
return `${height}px`; // 숫자면 px 추가
};
2025-10-16 18:16:57 +09:00
const componentStyle: React.CSSProperties = isPreview
? {
// 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용
2025-10-16 18:16:57 +09:00
position: "relative",
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
2025-11-05 16:18:00 +09:00
height: getHeightValue(),
2025-10-16 18:16:57 +09:00
border: "1px solid #e5e7eb",
}
: {
// 디자이너 모드: position absolute
position: "absolute",
left: `${component.style?.positionX || 0}px`,
top: `${component.style?.positionY || 0}px`,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 (그리드 기반)
2025-11-05 16:18:00 +09:00
height: getHeightValue(),
2025-10-16 18:16:57 +09:00
zIndex: component.style?.positionZ || 1,
cursor: isDesignMode ? "pointer" : "default",
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
};
2025-10-15 17:25:38 +09:00
2025-11-07 15:21:44 +09:00
// 계층 구조 빌드 함수 (트리 구조 유지)
const buildHierarchy = useCallback(
(items: any[]): any[] => {
if (!items || items.length === 0) return [];
2025-11-07 15:21:44 +09:00
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
if (!itemAddConfig) return items.map((item) => ({ ...item, children: [] })); // 계층 설정이 없으면 평면 목록
2025-11-07 15:21:44 +09:00
const { sourceColumn, parentColumn } = itemAddConfig;
if (!sourceColumn || !parentColumn) return items.map((item) => ({ ...item, children: [] }));
2025-11-07 15:21:44 +09:00
// ID를 키로 하는 맵 생성
const itemMap = new Map<any, any>();
const rootItems: any[] = [];
// 모든 항목을 맵에 추가하고 children 배열 초기화
items.forEach((item) => {
const id = item[sourceColumn];
itemMap.set(id, { ...item, children: [], level: 0 });
});
// 부모-자식 관계 설정
items.forEach((item) => {
const id = item[sourceColumn];
const parentId = item[parentColumn];
const currentItem = itemMap.get(id);
if (!currentItem) return;
if (!parentId || parentId === null || parentId === "") {
// 최상위 항목
2025-11-07 15:21:44 +09:00
rootItems.push(currentItem);
} else {
// 부모가 있는 항목
const parentItem = itemMap.get(parentId);
if (parentItem) {
currentItem.level = parentItem.level + 1;
parentItem.children.push(currentItem);
} else {
// 부모를 찾을 수 없으면 최상위로 처리
rootItems.push(currentItem);
}
2025-11-07 15:21:44 +09:00
}
});
2025-11-07 15:21:44 +09:00
return rootItems;
},
[componentConfig.leftPanel?.itemAddConfig],
);
2025-11-07 15:21:44 +09:00
// 🔧 사용자 ID 가져오기
const { userId: currentUserId } = useAuth();
// 🔄 필터를 searchValues 형식으로 변환
const searchValues = useMemo(() => {
if (!leftFilters || leftFilters.length === 0) return {};
const values: Record<string, any> = {};
leftFilters.forEach((filter) => {
if (filter.value !== undefined && filter.value !== null && filter.value !== "") {
values[filter.columnName] = {
value: filter.value,
operator: filter.operator || "contains",
};
}
});
return values;
}, [leftFilters]);
// 🔄 컬럼 가시성 및 순서 처리
const visibleLeftColumns = useMemo(() => {
const displayColumns = componentConfig.leftPanel?.columns || [];
if (displayColumns.length === 0) return [];
let columns = displayColumns;
// columnVisibility가 있으면 가시성 적용
if (leftColumnVisibility.length > 0) {
const visibilityMap = new Map(leftColumnVisibility.map((cv) => [cv.columnName, cv.visible]));
columns = columns.filter((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
return visibilityMap.get(colName) !== false;
});
}
// 🔧 컬럼 순서 적용
if (leftColumnOrder.length > 0) {
const orderMap = new Map(leftColumnOrder.map((name, index) => [name, index]));
columns = [...columns].sort((a, b) => {
const aName = typeof a === "string" ? a : a.name || a.columnName;
const bName = typeof b === "string" ? b : b.name || b.columnName;
const aIndex = orderMap.get(aName) ?? 999;
const bIndex = orderMap.get(bName) ?? 999;
return aIndex - bIndex;
});
}
return columns;
}, [componentConfig.leftPanel?.columns, leftColumnVisibility, leftColumnOrder]);
// 🔄 데이터 그룹화
const groupedLeftData = useMemo(() => {
if (!leftGrouping || leftGrouping.length === 0 || leftData.length === 0) return [];
const grouped = new Map<string, any[]>();
leftData.forEach((item) => {
// 각 그룹 컬럼의 값을 조합하여 그룹 키 생성
const groupKey = leftGrouping
.map((col) => {
const value = item[col];
// null/undefined 처리
return value === null || value === undefined ? "(비어있음)" : String(value);
})
.join(" > ");
if (!grouped.has(groupKey)) {
grouped.set(groupKey, []);
}
grouped.get(groupKey)!.push(item);
});
return Array.from(grouped.entries()).map(([key, items]) => ({
groupKey: key,
items,
count: items.length,
}));
}, [leftData, leftGrouping]);
2025-12-11 11:37:40 +09:00
// 날짜 포맷팅 헬퍼 함수
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(
(
columnName: string,
value: any,
categoryMappings: Record<string, Record<string, { label: string; color?: string }>>,
2025-12-11 11:37:40 +09:00
format?: {
type?: "number" | "currency" | "date" | "text";
thousandSeparator?: boolean;
decimalPlaces?: number;
prefix?: string;
suffix?: string;
dateFormat?: string;
},
) => {
if (value === null || value === undefined) return "-";
2025-12-11 11:37:40 +09:00
// 🆕 날짜 포맷 적용
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);
}
2025-12-04 18:26:35 +09:00
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
// 1. 전체 컬럼명 (예: "item_info.material")
// 2. 컬럼명만 (예: "material")
let mapping = categoryMappings[columnName];
2025-12-11 11:37:40 +09:00
2025-12-04 18:26:35 +09:00
if (!mapping && columnName.includes(".")) {
// 조인된 컬럼의 경우 컬럼명만으로 다시 시도
const simpleColumnName = columnName.split(".").pop() || columnName;
mapping = categoryMappings[simpleColumnName];
}
2025-12-11 11:37:40 +09:00
if (mapping && mapping[String(value)]) {
const categoryData = mapping[String(value)];
const displayLabel = categoryData.label || String(value);
const displayColor = categoryData.color || "#64748b";
// 배지로 표시
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
}
2025-12-11 11:37:40 +09:00
// 🆕 자동 날짜 감지 (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);
},
2025-12-11 11:37:40 +09:00
[formatDateValue, formatNumberValue],
);
2025-10-15 17:25:38 +09:00
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
setIsLoadingLeft(true);
try {
// 🎯 필터 조건을 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)
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
// 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼)
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, {
2025-10-15 17:25:38 +09:00
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) {
result.data.sort((a, b) => {
const aValue = String(a[leftColumn] || "");
const bValue = String(b[leftColumn] || "");
return aValue.localeCompare(bValue, "ko-KR");
});
}
2025-11-07 15:21:44 +09:00
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(result.data);
setLeftData(hierarchicalData);
2025-10-15 17:25:38 +09:00
} catch (error) {
console.error("좌측 데이터 로드 실패:", error);
toast({
title: "데이터 로드 실패",
description: "좌측 패널 데이터를 불러올 수 없습니다.",
variant: "destructive",
});
} finally {
setIsLoadingLeft(false);
}
}, [
componentConfig.leftPanel?.tableName,
componentConfig.leftPanel?.columns,
componentConfig.leftPanel?.dataFilter,
componentConfig.rightPanel?.relation?.leftColumn,
isDesignMode,
toast,
buildHierarchy,
searchValues,
]);
2025-10-15 17:25:38 +09:00
// 우측 데이터 로드
const loadRightData = useCallback(
async (leftItem: any) => {
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
setIsLoadingRight(true);
try {
if (relationshipType === "detail") {
// 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화)
2025-10-15 17:25:38 +09:00
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
2025-12-11 11:37:40 +09:00
// 🆕 엔티티 조인 API 사용
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: { id: primaryKey },
enableEntityJoin: true, // 엔티티 조인 활성화
size: 1,
});
2025-12-11 11:37:40 +09:00
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
2025-10-15 17:25:38 +09:00
setRightData(detail);
} else if (relationshipType === "join") {
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
2025-12-11 11:37:40 +09:00
const keys = componentConfig.rightPanel?.relation?.keys;
2025-10-15 17:25:38 +09:00
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;
}
2025-12-11 11:37:40 +09:00
// 🆕 복합키 지원
if (keys && keys.length > 0 && leftTable) {
// 복합키: 여러 조건으로 필터링
const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 복합키 조건 생성
const searchConditions: Record<string, any> = {};
keys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
}
});
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 || []); // 모든 관련 레코드 (배열)
}
2025-10-15 17:25:38 +09:00
}
}
} catch (error) {
console.error("우측 데이터 로드 실패:", error);
toast({
title: "데이터 로드 실패",
description: "우측 패널 데이터를 불러올 수 없습니다.",
variant: "destructive",
});
} finally {
setIsLoadingRight(false);
}
},
[
componentConfig.rightPanel?.tableName,
componentConfig.rightPanel?.relation,
componentConfig.leftPanel?.tableName,
isDesignMode,
toast,
],
);
// 좌측 항목 선택 핸들러
const handleLeftItemSelect = useCallback(
(item: any) => {
setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
2025-10-15 17:25:38 +09:00
loadRightData(item);
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
const leftTableName = componentConfig.leftPanel?.tableName;
if (leftTableName && !isDesignMode) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(leftTableName, [item]);
console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item);
});
}
2025-10-15 17:25:38 +09:00
},
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
2025-10-15 17:25:38 +09:00
);
// 우측 항목 확장/축소 토글
const toggleRightItemExpansion = useCallback((itemId: string | number) => {
setExpandedRightItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
}, []);
// 컬럼명을 라벨로 변환하는 함수
const getColumnLabel = useCallback(
(columnName: string) => {
const column = rightTableColumns.find((col) => col.columnName === columnName || col.column_name === columnName);
return column?.columnLabel || column?.column_label || column?.displayName || columnName;
},
[rightTableColumns],
);
// 🔧 컬럼의 고유값 가져오기 함수
const getLeftColumnUniqueValues = useCallback(
async (columnName: string) => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || leftData.length === 0) return [];
// 현재 로드된 데이터에서 고유값 추출
const uniqueValues = new Set<string>();
leftData.forEach((item) => {
2025-12-12 13:50:33 +09:00
// 🆕 조인 컬럼 처리 (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];
2025-12-12 13:50:33 +09:00
// 🆕 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];
2025-12-12 13:50:33 +09:00
// 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;
uniqueValues.add(String(displayValue));
}
});
return Array.from(uniqueValues).map((value) => ({
value: value,
label: value,
}));
},
[componentConfig.leftPanel?.tableName, leftData],
);
// 좌측 테이블 등록 (Context에 등록)
useEffect(() => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
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;
return col.columnName || col.name || col;
})
.filter(Boolean);
// 화면에 설정된 컬럼이 없으면 등록하지 않음
if (displayColumns.length === 0) return;
// 테이블명이 있으면 등록
registerTable({
tableId: leftTableId,
label: `${component.title || "분할 패널"} (좌측)`,
tableName: leftTableName,
columns: displayColumns.map((col: string) => ({
columnName: col,
// 🆕 우선순위: 1) 설정에서 지정한 라벨 2) DB 라벨 3) 컬럼명
columnLabel: configuredLabels[col] || leftColumnLabels[col] || col,
inputType: "text",
visible: true,
width: 150,
sortable: true,
filterable: true,
})),
onFilterChange: setLeftFilters,
onGroupChange: setLeftGrouping,
onColumnVisibilityChange: setLeftColumnVisibility,
onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가
getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가
onGroupSumChange: setLeftGroupSumConfig, // 🆕 그룹별 합산 설정 콜백
});
return () => unregisterTable(leftTableId);
}, [
component.id,
componentConfig.leftPanel?.tableName,
componentConfig.leftPanel?.columns,
leftColumnLabels,
component.title,
isDesignMode,
getLeftColumnUniqueValues,
]);
// 우측 테이블은 검색 컴포넌트 등록 제외 (좌측 마스터 테이블만 검색 가능)
// useEffect(() => {
// const rightTableName = componentConfig.rightPanel?.tableName;
// if (!rightTableName || isDesignMode) return;
//
// const rightTableId = `split-panel-right-${component.id}`;
// // 🔧 화면에 표시되는 컬럼만 등록 (displayColumns 또는 columns)
// const displayColumns = componentConfig.rightPanel?.columns || [];
// const rightColumns = displayColumns.map((col: any) => col.columnName || col.name || col).filter(Boolean);
//
// if (rightColumns.length > 0) {
// registerTable({
// tableId: rightTableId,
// label: `${component.title || "분할 패널"} (우측)`,
// tableName: rightTableName,
// columns: rightColumns.map((col: string) => ({
// columnName: col,
// columnLabel: rightColumnLabels[col] || col,
// inputType: "text",
// visible: true,
// width: 150,
// sortable: true,
// filterable: true,
// })),
// onFilterChange: setRightFilters,
// onGroupChange: setRightGrouping,
// onColumnVisibilityChange: setRightColumnVisibility,
// });
//
// return () => unregisterTable(rightTableId);
// }
// }, [component.id, componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, rightColumnLabels, component.title, isDesignMode]);
// 좌측 테이블 컬럼 라벨 로드
useEffect(() => {
const loadLeftColumnLabels = async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
try {
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
const labels: Record<string, string> = {};
columnsResponse.forEach((col: any) => {
const columnName = col.columnName || col.column_name;
const label = col.columnLabel || col.column_label || col.displayName || columnName;
if (columnName) {
labels[columnName] = label;
}
});
setLeftColumnLabels(labels);
console.log("✅ 좌측 컬럼 라벨 로드:", labels);
} catch (error) {
console.error("좌측 테이블 컬럼 라벨 로드 실패:", error);
}
};
loadLeftColumnLabels();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
// 우측 테이블 컬럼 정보 로드
useEffect(() => {
const loadRightTableColumns = async () => {
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
try {
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
setRightTableColumns(columnsResponse || []);
// 우측 컬럼 라벨도 함께 로드
const labels: Record<string, string> = {};
columnsResponse.forEach((col: any) => {
const columnName = col.columnName || col.column_name;
const label = col.columnLabel || col.column_label || col.displayName || columnName;
if (columnName) {
labels[columnName] = label;
}
});
setRightColumnLabels(labels);
console.log("✅ 우측 컬럼 라벨 로드:", labels);
} catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
}
};
loadRightTableColumns();
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
// 좌측 테이블 카테고리 매핑 로드
useEffect(() => {
const loadLeftCategoryMappings = async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
try {
// 1. 컬럼 메타 정보 조회
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
if (categoryColumns.length === 0) {
setLeftCategoryMappings({});
return;
}
// 2. 각 카테고리 컬럼에 대한 값 조회
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
try {
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`);
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
mappings[columnName] = valueMap;
console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
}
} catch (error) {
console.error(`좌측 카테고리 값 조회 실패 [${columnName}]:`, error);
}
}
setLeftCategoryMappings(mappings);
} catch (error) {
console.error("좌측 카테고리 매핑 로드 실패:", error);
}
};
loadLeftCategoryMappings();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
2025-12-04 18:26:35 +09:00
// 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
useEffect(() => {
const loadRightCategoryMappings = async () => {
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
try {
2025-12-04 18:26:35 +09:00
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
2025-12-04 18:26:35 +09:00
// 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출
const rightColumns = componentConfig.rightPanel?.columns || [];
const tablesToLoad = new Set<string>([rightTableName]);
2025-12-11 11:37:40 +09:00
2025-12-04 18:26:35 +09:00
// 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info")
rightColumns.forEach((col: any) => {
const colName = col.name || col.columnName;
if (colName && colName.includes(".")) {
const joinTableName = colName.split(".")[0];
tablesToLoad.add(joinTableName);
}
});
2025-12-04 18:26:35 +09:00
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
2025-12-04 18:26:35 +09:00
// 각 테이블에 대해 카테고리 매핑 로드
for (const tableName of tablesToLoad) {
try {
2025-12-04 18:26:35 +09:00
// 1. 컬럼 메타 정보 조회
const columnsResponse = await tableTypeApi.getColumns(tableName);
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
// 2. 각 카테고리 컬럼에 대한 값 조회
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
try {
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
2025-12-11 11:37:40 +09:00
2025-12-04 18:26:35 +09:00
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
mappings[mappingKey] = valueMap;
2025-12-11 11:37:40 +09:00
2025-12-04 18:26:35 +09:00
// 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
// 기존 매핑이 있으면 병합, 없으면 새로 생성
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
2025-12-11 11:37:40 +09:00
2025-12-04 18:26:35 +09:00
console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap);
console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]);
}
} catch (error) {
console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error);
}
}
} catch (error) {
2025-12-04 18:26:35 +09:00
console.error(`테이블 ${tableName} 컬럼 정보 조회 실패:`, error);
}
}
setRightCategoryMappings(mappings);
} catch (error) {
console.error("우측 카테고리 매핑 로드 실패:", error);
}
};
loadRightCategoryMappings();
2025-12-04 18:26:35 +09:00
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]);
2025-11-07 15:21:44 +09:00
// 항목 펼치기/접기 토글
const toggleExpand = useCallback((itemId: any) => {
setExpandedItems((prev) => {
2025-11-07 15:21:44 +09:00
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
}, []);
// 추가 버튼 핸들러
const handleAddClick = useCallback(
(panel: "left" | "right") => {
setAddModalPanel(panel);
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
if (
panel === "right" &&
selectedLeftItem &&
componentConfig.leftPanel?.leftColumn &&
componentConfig.rightPanel?.rightColumn
) {
const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn];
setAddModalFormData({
[componentConfig.rightPanel.rightColumn]: leftColumnValue,
});
} else {
setAddModalFormData({});
}
setShowAddModal(true);
},
[selectedLeftItem, componentConfig],
);
2025-11-07 16:02:01 +09:00
// 수정 버튼 핸들러
const handleEditClick = useCallback(
(panel: "left" | "right", item: any) => {
// 🆕 우측 패널 수정 버튼 설정 확인
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
2025-12-11 11:37:40 +09:00
if (modalScreenId) {
// 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || "";
2025-12-11 11:37:40 +09:00
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
let primaryKeyName = "id";
let primaryKeyValue: any;
2025-12-11 11:37:40 +09:00
if (item.id !== undefined && item.id !== null) {
primaryKeyName = "id";
primaryKeyValue = item.id;
} else if (item.ID !== undefined && item.ID !== null) {
primaryKeyName = "ID";
primaryKeyValue = item.ID;
} else {
// 첫 번째 필드를 Primary Key로 간주
const firstKey = Object.keys(item)[0];
primaryKeyName = firstKey;
primaryKeyValue = item[firstKey];
}
2025-12-11 11:37:40 +09:00
console.log("✅ 수정 모달 열기:", {
tableName: rightTableName,
primaryKeyName,
primaryKeyValue,
screenId: modalScreenId,
fullItem: item,
});
2025-12-11 11:37:40 +09:00
// modalDataStore에도 저장 (호환성 유지)
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(rightTableName, [item]);
});
2025-12-11 11:37:40 +09:00
// 🆕 groupByColumns 추출
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
2025-12-11 11:37:40 +09:00
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
groupByColumns,
editButtonConfig: componentConfig.rightPanel?.editButton,
hasGroupByColumns: groupByColumns.length > 0,
});
2025-12-11 11:37:40 +09:00
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달)
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId: modalScreenId,
urlParams: {
mode: "edit",
editId: primaryKeyValue,
tableName: rightTableName,
...(groupByColumns.length > 0 && {
groupByColumns: JSON.stringify(groupByColumns),
}),
},
},
}),
);
2025-12-11 11:37:40 +09:00
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", {
screenId: modalScreenId,
editId: primaryKeyValue,
tableName: rightTableName,
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
});
2025-12-11 11:37:40 +09:00
return;
}
}
2025-12-11 11:37:40 +09:00
// 기존 자동 편집 모드 (인라인 편집 모달)
setEditModalPanel(panel);
setEditModalItem(item);
setEditModalFormData({ ...item });
setShowEditModal(true);
},
[componentConfig],
);
2025-11-07 16:02:01 +09:00
// 수정 모달 저장
const handleEditModalSave = useCallback(async () => {
const tableName =
editModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName;
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
2025-11-07 16:02:01 +09:00
const primaryKey = editModalItem[sourceColumn] || editModalItem.id || editModalItem.ID;
if (!tableName || !primaryKey) {
toast({
title: "수정 오류",
description: "테이블명 또는 Primary Key가 없습니다.",
variant: "destructive",
});
return;
}
try {
console.log("📝 데이터 수정:", { tableName, primaryKey, data: editModalFormData });
2025-11-07 16:02:01 +09:00
// 프론트엔드 전용 필드 제거 (children, level 등)
const cleanData = { ...editModalFormData };
delete cleanData.children;
delete cleanData.level;
2025-11-07 16:02:01 +09:00
// 좌측 패널 수정 시, 조인 관계 정보 포함
const updatePayload: any = cleanData;
2025-11-07 16:02:01 +09:00
if (editModalPanel === "left" && componentConfig.rightPanel?.relation?.type === "join") {
// 조인 관계가 있는 경우, 관계 정보를 페이로드에 추가
updatePayload._relationInfo = {
rightTable: componentConfig.rightPanel.tableName,
leftColumn: componentConfig.rightPanel.relation.leftColumn,
rightColumn: componentConfig.rightPanel.relation.rightColumn,
oldLeftValue: editModalItem[componentConfig.rightPanel.relation.leftColumn],
};
console.log("🔗 조인 관계 정보 추가:", updatePayload._relationInfo);
}
2025-11-07 16:02:01 +09:00
const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload);
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 수정되었습니다.",
});
2025-11-07 16:02:01 +09:00
// 모달 닫기
setShowEditModal(false);
setEditModalFormData({});
setEditModalItem(null);
2025-11-07 16:02:01 +09:00
// 데이터 새로고침
if (editModalPanel === "left") {
loadLeftData();
// 우측 패널도 새로고침 (FK가 변경되었을 수 있음)
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} else if (editModalPanel === "right" && selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} else {
toast({
title: "수정 실패",
description: result.message || "데이터 수정에 실패했습니다.",
variant: "destructive",
});
}
} catch (error: any) {
console.error("데이터 수정 오류:", error);
toast({
title: "오류",
description: error?.response?.data?.message || "데이터 수정 중 오류가 발생했습니다.",
variant: "destructive",
});
}
}, [
editModalPanel,
componentConfig,
editModalItem,
editModalFormData,
toast,
selectedLeftItem,
loadLeftData,
loadRightData,
]);
2025-11-07 16:02:01 +09:00
// 삭제 버튼 핸들러
const handleDeleteClick = useCallback((panel: "left" | "right", item: any) => {
setDeleteModalPanel(panel);
setDeleteModalItem(item);
setShowDeleteModal(true);
}, []);
// 삭제 확인
const handleDeleteConfirm = useCallback(async () => {
// 우측 패널 삭제 시 중계 테이블 확인
let tableName =
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName;
// 우측 패널 + 중계 테이블 모드인 경우
if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) {
tableName = componentConfig.rightPanel.addConfig.targetTable;
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
}
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID;
// 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리)
if (deleteModalItem && typeof deleteModalItem === "object") {
primaryKey = deleteModalItem;
console.log("🔑 복합키 가능성: 전체 객체 전달", primaryKey);
}
2025-11-07 16:02:01 +09:00
if (!tableName || !primaryKey) {
toast({
title: "삭제 오류",
description: "테이블명 또는 Primary Key가 없습니다.",
variant: "destructive",
});
return;
}
try {
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
2025-12-11 11:37:40 +09:00
2025-11-20 11:58:43 +09:00
// 🔍 중복 제거 설정 디버깅
console.log("🔍 중복 제거 디버깅:", {
panel: deleteModalPanel,
dataFilter: componentConfig.rightPanel?.dataFilter,
deduplication: componentConfig.rightPanel?.dataFilter?.deduplication,
enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled,
});
2025-11-20 11:58:43 +09:00
let result;
// 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제
if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) {
const deduplication = componentConfig.rightPanel.dataFilter.deduplication;
const groupByColumn = deduplication.groupByColumn;
2025-12-11 11:37:40 +09:00
2025-11-20 11:58:43 +09:00
if (groupByColumn && deleteModalItem[groupByColumn]) {
const groupValue = deleteModalItem[groupByColumn];
console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`);
2025-12-11 11:37:40 +09:00
2025-11-20 11:58:43 +09:00
// groupByColumn 값으로 필터링하여 삭제
const filterConditions: Record<string, any> = {
[groupByColumn]: groupValue,
};
2025-12-11 11:37:40 +09:00
2025-11-20 11:58:43 +09:00
// 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등)
if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") {
const leftColumn = componentConfig.rightPanel.join.leftColumn;
const rightColumn = componentConfig.rightPanel.join.rightColumn;
filterConditions[rightColumn] = selectedLeftItem[leftColumn];
}
2025-12-11 11:37:40 +09:00
2025-11-20 11:58:43 +09:00
console.log("🗑️ 그룹 삭제 조건:", filterConditions);
2025-12-11 11:37:40 +09:00
2025-11-20 11:58:43 +09:00
// 그룹 삭제 API 호출
result = await dataApi.deleteGroupRecords(tableName, filterConditions);
} else {
// 단일 레코드 삭제
result = await dataApi.deleteRecord(tableName, primaryKey);
}
} else {
// 단일 레코드 삭제
result = await dataApi.deleteRecord(tableName, primaryKey);
}
2025-11-07 16:02:01 +09:00
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 삭제되었습니다.",
});
2025-11-07 16:02:01 +09:00
// 모달 닫기
setShowDeleteModal(false);
setDeleteModalItem(null);
2025-11-07 16:02:01 +09:00
// 데이터 새로고침
if (deleteModalPanel === "left") {
loadLeftData();
// 삭제된 항목이 선택되어 있었으면 선택 해제
if (selectedLeftItem && selectedLeftItem[sourceColumn] === primaryKey) {
setSelectedLeftItem(null);
setRightData(null);
}
} else if (deleteModalPanel === "right" && selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} else {
toast({
title: "삭제 실패",
description: result.message || "데이터 삭제에 실패했습니다.",
variant: "destructive",
});
}
} catch (error: any) {
console.error("데이터 삭제 오류:", error);
2025-11-07 16:02:01 +09:00
// 외래키 제약조건 에러 처리
let errorMessage = "데이터 삭제 중 오류가 발생했습니다.";
if (error?.response?.data?.error?.includes("foreign key")) {
errorMessage = "이 데이터를 참조하는 다른 데이터가 있어 삭제할 수 없습니다.";
}
2025-11-07 16:02:01 +09:00
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
}
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]);
2025-11-07 15:21:44 +09:00
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
const handleItemAddClick = useCallback(
(item: any) => {
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
2025-11-07 15:21:44 +09:00
if (!itemAddConfig) {
toast({
title: "설정 오류",
description: "하위 항목 추가 설정이 없습니다.",
variant: "destructive",
});
return;
}
2025-11-07 15:21:44 +09:00
const { sourceColumn, parentColumn } = itemAddConfig;
2025-11-07 15:21:44 +09:00
if (!sourceColumn || !parentColumn) {
toast({
title: "설정 오류",
description: "현재 항목 ID 컬럼과 상위 항목 저장 컬럼을 설정해주세요.",
variant: "destructive",
});
return;
}
// 선택된 항목의 sourceColumn 값을 가져와서 parentColumn에 매핑
const sourceValue = item[sourceColumn];
if (!sourceValue) {
toast({
title: "데이터 오류",
description: `선택한 항목의 ${sourceColumn} 값이 없습니다.`,
variant: "destructive",
});
return;
}
// 좌측 패널 추가 모달 열기 (parentColumn 값 미리 채우기)
setAddModalPanel("left-item");
setAddModalFormData({ [parentColumn]: sourceValue });
setShowAddModal(true);
},
[componentConfig, toast],
);
2025-11-07 15:21:44 +09:00
// 추가 모달 저장
const handleAddModalSave = useCallback(async () => {
2025-11-07 15:21:44 +09:00
// 테이블명과 모달 컬럼 결정
let tableName: string | undefined;
let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined;
const finalData = { ...addModalFormData };
2025-11-07 15:21:44 +09:00
if (addModalPanel === "left") {
tableName = componentConfig.leftPanel?.tableName;
modalColumns = componentConfig.leftPanel?.addModalColumns;
} else if (addModalPanel === "right") {
// 우측 패널: 중계 테이블 설정이 있는지 확인
const addConfig = componentConfig.rightPanel?.addConfig;
if (addConfig?.targetTable) {
// 중계 테이블 모드
tableName = addConfig.targetTable;
modalColumns = componentConfig.rightPanel?.addModalColumns;
// 좌측 패널에서 선택된 값 자동 채우기
if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) {
const leftValue = selectedLeftItem[addConfig.leftPanelColumn];
finalData[addConfig.targetColumn] = leftValue;
console.log(`🔗 좌측 패널 값 자동 채움: ${addConfig.targetColumn} = ${leftValue}`);
}
// 자동 채움 컬럼 추가
if (addConfig.autoFillColumns) {
Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => {
finalData[key] = value;
});
console.log("🔧 자동 채움 컬럼:", addConfig.autoFillColumns);
}
} else {
// 일반 테이블 모드
tableName = componentConfig.rightPanel?.tableName;
modalColumns = componentConfig.rightPanel?.addModalColumns;
}
2025-11-07 15:21:44 +09:00
} else if (addModalPanel === "left-item") {
// 하위 항목 추가 (좌측 테이블에 추가)
tableName = componentConfig.leftPanel?.tableName;
modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns;
}
if (!tableName) {
toast({
title: "테이블 오류",
description: "테이블명이 설정되지 않았습니다.",
variant: "destructive",
});
return;
}
// 필수 필드 검증
const requiredFields = (modalColumns || []).filter((col) => col.required);
for (const field of requiredFields) {
if (!addModalFormData[field.name]) {
toast({
title: "입력 오류",
description: `${field.label}은(는) 필수 입력 항목입니다.`,
variant: "destructive",
});
return;
}
}
try {
console.log("📝 데이터 추가:", { tableName, data: finalData });
const result = await dataApi.createRecord(tableName, finalData);
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 추가되었습니다.",
});
// 모달 닫기
setShowAddModal(false);
setAddModalFormData({});
// 데이터 새로고침
2025-11-07 15:21:44 +09:00
if (addModalPanel === "left" || addModalPanel === "left-item") {
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
loadLeftData();
2025-11-07 15:21:44 +09:00
} else if (addModalPanel === "right" && selectedLeftItem) {
// 우측 패널 데이터 새로고침
loadRightData(selectedLeftItem);
}
} else {
toast({
title: "저장 실패",
description: result.message || "데이터 추가에 실패했습니다.",
variant: "destructive",
});
}
} catch (error: any) {
console.error("데이터 추가 오류:", error);
// 에러 메시지 추출
let errorMessage = "데이터 추가 중 오류가 발생했습니다.";
if (error?.response?.data) {
const responseData = error.response.data;
// 백엔드에서 반환한 에러 메시지 확인
if (responseData.error) {
// 중복 키 에러 처리
if (responseData.error.includes("duplicate key")) {
errorMessage = "이미 존재하는 값입니다. 다른 값을 입력해주세요.";
}
// NOT NULL 제약조건 에러
else if (responseData.error.includes("null value")) {
const match = responseData.error.match(/column "(\w+)"/);
const columnName = match ? match[1] : "필수";
errorMessage = `${columnName} 필드는 필수 입력 항목입니다.`;
}
// 외래키 제약조건 에러
else if (responseData.error.includes("foreign key")) {
errorMessage = "참조하는 데이터가 존재하지 않습니다.";
}
// 기타 에러
else {
errorMessage = responseData.message || responseData.error;
}
} else if (responseData.message) {
errorMessage = responseData.message;
}
}
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
}
}, [addModalPanel, componentConfig, addModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]);
// 🔧 좌측 컬럼 가시성 설정 저장 및 불러오기
useEffect(() => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (leftTableName && currentUserId) {
// localStorage에서 저장된 설정 불러오기
const storageKey = `table_column_visibility_${leftTableName}_${currentUserId}`;
const savedSettings = localStorage.getItem(storageKey);
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
setLeftColumnVisibility(parsed);
} catch (error) {
console.error("저장된 컬럼 설정 불러오기 실패:", error);
}
}
}
}, [componentConfig.leftPanel?.tableName, currentUserId]);
// 🔧 컬럼 가시성 변경 시 localStorage에 저장 및 순서 업데이트
useEffect(() => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (leftColumnVisibility.length > 0 && leftTableName && currentUserId) {
// 순서 업데이트
const newOrder = leftColumnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외
setLeftColumnOrder(newOrder);
// localStorage에 저장
const storageKey = `table_column_visibility_${leftTableName}_${currentUserId}`;
localStorage.setItem(storageKey, JSON.stringify(leftColumnVisibility));
}
}, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]);
2025-10-15 17:25:38 +09:00
// 초기 데이터 로드
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, componentConfig.autoLoad]);
// 🔄 필터 변경 시 데이터 다시 로드
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftFilters]);
// 🆕 전역 테이블 새로고침 이벤트 리스너
useEffect(() => {
const handleRefreshTable = () => {
if (!isDesignMode) {
console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
loadLeftData();
// 선택된 항목이 있으면 우측 패널도 새로고침
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
}
};
window.addEventListener("refreshTable", handleRefreshTable);
return () => {
window.removeEventListener("refreshTable", handleRefreshTable);
};
}, [isDesignMode, loadLeftData, loadRightData, selectedLeftItem]);
2025-10-15 17:25:38 +09:00
// 리사이저 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
if (!resizable) return;
setIsDragging(true);
e.preventDefault();
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
2025-10-15 17:25:38 +09:00
const containerRect = containerRef.current.getBoundingClientRect();
const containerWidth = containerRect.width;
const relativeX = e.clientX - containerRect.left;
const newLeftWidth = (relativeX / containerWidth) * 100;
// 최소/최대 너비 제한 (20% ~ 80%)
if (newLeftWidth >= 20 && newLeftWidth <= 80) {
2025-10-15 17:25:38 +09:00
setLeftWidth(newLeftWidth);
}
},
[isDragging],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
React.useEffect(() => {
if (isDragging) {
// 드래그 중에는 텍스트 선택 방지
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", handleMouseMove);
2025-10-15 17:25:38 +09:00
document.addEventListener("mouseup", handleMouseUp);
2025-10-15 17:25:38 +09:00
return () => {
document.body.style.userSelect = "";
document.body.style.cursor = "";
document.removeEventListener("mousemove", handleMouseMove);
2025-10-15 17:25:38 +09:00
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<div
ref={containerRef}
2025-11-05 16:18:00 +09:00
style={{
...(isPreview
? {
position: "relative",
height: `${component.style?.height || 600}px`,
border: "1px solid #e5e7eb",
}
2025-11-05 16:18:00 +09:00
: componentStyle),
display: "flex",
flexDirection: "row",
}}
2025-10-15 17:25:38 +09:00
onClick={(e) => {
if (isDesignMode) {
e.stopPropagation();
onClick?.(e);
}
}}
2025-11-05 16:18:00 +09:00
className="w-full overflow-hidden rounded-lg bg-white shadow-sm"
2025-10-15 17:25:38 +09:00
>
{/* 좌측 패널 */}
<div
2025-11-05 16:18:00 +09:00
style={{ width: `${leftWidth}%`, minWidth: isPreview ? "0" : `${minLeftWidth}px`, height: "100%" }}
2025-10-17 16:21:08 +09:00
className="border-border flex flex-shrink-0 flex-col border-r"
2025-10-15 17:25:38 +09:00
>
2025-11-05 16:18:00 +09:00
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
2025-10-17 16:21:08 +09:00
<CardHeader className="border-b pb-3">
2025-10-15 17:25:38 +09:00
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">
{componentConfig.leftPanel?.title || "좌측 패널"}
</CardTitle>
2025-11-07 16:02:01 +09:00
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
2025-10-15 17:25:38 +09:00
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
</div>
{componentConfig.leftPanel?.showSearch && (
<div className="relative mt-2">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
2025-10-15 17:25:38 +09:00
<Input
placeholder="검색..."
value={leftSearchQuery}
onChange={(e) => setLeftSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
)}
</CardHeader>
2025-11-05 16:18:00 +09:00
<CardContent className="flex-1 overflow-auto p-4">
{/* 좌측 데이터 목록/테이블 */}
{componentConfig.leftPanel?.displayMode === "table" ? (
// 테이블 모드
<div className="w-full">
{isDesignMode ? (
// 디자인 모드: 샘플 테이블
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 1</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 2</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 3</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
<tr className="cursor-pointer hover:bg-gray-50">
<td className="px-3 py-2 text-sm whitespace-nowrap"> 1-1</td>
<td className="px-3 py-2 text-sm whitespace-nowrap"> 1-2</td>
<td className="px-3 py-2 text-sm whitespace-nowrap"> 1-3</td>
</tr>
<tr className="cursor-pointer hover:bg-gray-50">
<td className="px-3 py-2 text-sm whitespace-nowrap"> 2-1</td>
<td className="px-3 py-2 text-sm whitespace-nowrap"> 2-2</td>
<td className="px-3 py-2 text-sm whitespace-nowrap"> 2-3</td>
</tr>
</tbody>
</table>
2025-10-15 17:25:38 +09:00
</div>
) : isLoadingLeft ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
2025-10-15 17:25:38 +09:00
</div>
) : (
(() => {
// 🆕 그룹별 합산된 데이터 사용
const dataSource = summedLeftData;
console.log(
"🔍 [테이블모드 렌더링] dataSource 개수:",
dataSource.length,
"leftGroupSumConfig:",
leftGroupSumConfig,
);
// 🔧 로컬 검색 필터 적용
const filteredData = leftSearchQuery
? 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);
});
})
: dataSource;
// 🔧 가시성 처리된 컬럼 사용
const columnsToShow =
visibleLeftColumns.length > 0
? visibleLeftColumns.map((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
return {
name: colName,
label:
leftColumnLabels[colName] || (typeof col === "object" ? col.label : null) || colName,
width: typeof col === "object" ? col.width : 150,
align: (typeof col === "object" ? col.align : "left") as "left" | "center" | "right",
2025-12-11 11:37:40 +09:00
format: typeof col === "object" ? col.format : undefined, // 🆕 포맷 설정 포함
};
})
: Object.keys(filteredData[0] || {})
.filter((key) => key !== "children" && key !== "level")
.slice(0, 5)
.map((key) => ({
name: key,
label: leftColumnLabels[key] || key,
width: 150,
align: "left" as const,
2025-12-11 11:37:40 +09:00
format: undefined, // 🆕 기본값
}));
// 🔧 그룹화된 데이터 렌더링
if (groupedLeftData.length > 0) {
return (
<div className="overflow-auto">
{groupedLeftData.map((group, groupIdx) => (
<div key={groupIdx} className="mb-4">
<div className="bg-gray-100 px-3 py-2 text-sm font-semibold">
{group.groupKey} ({group.count})
</div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
style={{
width: col.width ? `${col.width}px` : "auto",
textAlign: col.align || "left",
}}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{group.items.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || idx;
const isSelected =
selectedLeftItem &&
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return (
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
style={{ textAlign: col.align || "left" }}
>
2025-12-11 11:37:40 +09:00
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
2025-12-11 11:37:40 +09:00
leftCategoryMappings,
col.format,
)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
))}
</div>
);
}
// 🔧 일반 테이블 렌더링 (그룹화 없음)
return (
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 z-10 bg-gray-50">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
style={{
width: col.width ? `${col.width}px` : "auto",
textAlign: col.align || "left",
}}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || idx;
const isSelected =
selectedLeftItem &&
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return (
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
leftCategoryMappings,
col.format,
)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
})()
)}
</div>
) : (
// 목록 모드 (기존)
<div className="space-y-1">
{isDesignMode ? (
// 디자인 모드: 샘플 데이터
<>
<div
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 1</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 2</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 3</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
</>
) : isLoadingLeft ? (
// 로딩 중
<div className="flex items-center justify-center py-8">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
2025-10-15 17:25:38 +09:00
</div>
) : (
(() => {
// 🆕 그룹별 합산된 데이터 사용
const dataToDisplay = summedLeftData;
console.log(
"🔍 [렌더링] dataToDisplay 개수:",
dataToDisplay.length,
"leftGroupSumConfig:",
leftGroupSumConfig,
);
// 검색 필터링 (클라이언트 사이드)
const filteredLeftData = leftSearchQuery
? 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);
});
})
: dataToDisplay;
// 재귀 렌더링 함수
const renderTreeItem = (item: any, index: number): React.ReactNode => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || index;
const isSelected =
selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.has(itemId);
const level = item.level || 0;
// 🔧 수정: "표시할 컬럼 선택"에서 설정한 컬럼을 우선 사용
const configuredColumns = componentConfig.leftPanel?.columns || [];
let displayFields: { label: string; value: any }[] = [];
// 디버그 로그
if (index === 0) {
console.log("🔍 좌측 패널 표시 로직:");
console.log(" - 설정된 표시 컬럼:", configuredColumns);
console.log(" - item keys:", Object.keys(item));
}
if (configuredColumns.length > 0) {
// 🔧 "표시할 컬럼 선택"에서 설정한 컬럼 사용
displayFields = configuredColumns.slice(0, 2).map((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
const colLabel = typeof col === "object" ? col.label : leftColumnLabels[colName] || colName;
return {
label: colLabel,
value: item[colName],
};
});
if (index === 0) {
console.log(" ✅ 설정된 컬럼 기반 표시:", displayFields);
}
} else {
// 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시
const keys = Object.keys(item).filter(
2025-12-11 11:37:40 +09:00
(k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k),
);
displayFields = keys.slice(0, 2).map((key) => ({
label: leftColumnLabels[key] || key,
value: item[key],
}));
if (index === 0) {
console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields);
}
}
const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`;
const displaySubtitle = displayFields[1]?.value || null;
return (
<React.Fragment key={itemId}>
{/* 현재 항목 */}
<div
className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${
isSelected ? "bg-primary/10 text-primary" : "text-foreground"
}`}
style={{ paddingLeft: `${12 + level * 24}px` }}
2025-11-07 15:21:44 +09:00
>
<div
className="flex items-center gap-2"
onClick={() => {
handleLeftItemSelect(item);
if (hasChildren) {
toggleExpand(itemId);
}
}}
>
{/* 펼치기/접기 아이콘 */}
{hasChildren ? (
<div className="flex-shrink-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</div>
) : (
<div className="w-5" />
)}
{/* 항목 내용 */}
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{displayTitle}</div>
{displaySubtitle && (
<div className="text-muted-foreground truncate text-xs">{displaySubtitle}</div>
2025-11-07 15:21:44 +09:00
)}
</div>
{/* 항목별 버튼들 */}
{!isDesignMode && (
<div className="flex flex-shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{/* 수정 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
{/* 삭제 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
{/* 항목별 추가 버튼 */}
{componentConfig.leftPanel?.showItemAddButton && (
<button
onClick={(e) => {
e.stopPropagation();
handleItemAddClick(item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
title="하위 항목 추가"
>
<Plus className="h-4 w-4 text-gray-600" />
</button>
)}
</div>
)}
</div>
2025-11-07 15:21:44 +09:00
</div>
{/* 자식 항목들 (접혀있으면 표시 안함) */}
{hasChildren &&
isExpanded &&
item.children.map((child: any, childIndex: number) => renderTreeItem(child, childIndex))}
</React.Fragment>
);
};
return filteredLeftData.length > 0 ? (
// 실제 데이터 표시
filteredLeftData.map((item, index) => renderTreeItem(item, index))
) : (
// 검색 결과 없음
<div className="text-muted-foreground py-8 text-center text-sm">
{leftSearchQuery ? (
<>
<p> .</p>
<p className="text-muted-foreground/70 mt-1 text-xs"> .</p>
</>
) : (
"데이터가 없습니다."
)}
</div>
);
})()
)}
</div>
)}
2025-10-15 17:25:38 +09:00
</CardContent>
</Card>
</div>
{/* 리사이저 */}
{resizable && (
<div
onMouseDown={handleMouseDown}
className="group bg-border hover:bg-primary flex w-1 cursor-col-resize items-center justify-center transition-colors"
>
<GripVertical className="text-muted-foreground group-hover:text-primary-foreground h-4 w-4" />
2025-10-15 17:25:38 +09:00
</div>
)}
{/* 우측 패널 */}
<div
2025-11-05 16:18:00 +09:00
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px`, height: "100%" }}
className="flex flex-shrink-0 flex-col"
>
2025-11-05 16:18:00 +09:00
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
2025-10-17 16:21:08 +09:00
<CardHeader className="border-b pb-3">
2025-10-15 17:25:38 +09:00
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"}
</CardTitle>
2025-11-07 16:02:01 +09:00
{!isDesignMode && (
<div className="flex items-center gap-2">
{componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
2025-11-07 16:02:01 +09:00
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
</div>
2025-10-15 17:25:38 +09:00
)}
</div>
{componentConfig.rightPanel?.showSearch && (
<div className="relative mt-2">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
2025-10-15 17:25:38 +09:00
<Input
placeholder="검색..."
value={rightSearchQuery}
onChange={(e) => setRightSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
)}
</CardHeader>
2025-11-05 16:18:00 +09:00
<CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터 */}
2025-10-15 17:25:38 +09:00
{isLoadingRight ? (
// 로딩 중
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
2025-10-15 17:25:38 +09:00
</div>
</div>
) : rightData ? (
// 실제 데이터 표시
Array.isArray(rightData) ? (
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
(() => {
// 검색 필터링
const filteredData = rightSearchQuery
? rightData.filter((item) => {
const searchLower = rightSearchQuery.toLowerCase();
return Object.entries(item).some(([key, value]) => {
if (value === null || value === undefined) return false;
return String(value).toLowerCase().includes(searchLower);
});
})
: rightData;
// 테이블 모드 체크
const isTableMode = componentConfig.rightPanel?.displayMode === "table";
if (isTableMode) {
// 테이블 모드 렌더링
const displayColumns = componentConfig.rightPanel?.columns || [];
// 🆕 그룹 합산 모드일 때: 복합키 컬럼을 우선 표시
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">
<div className="text-muted-foreground mb-2 text-xs">
{filteredData.length}
{rightSearchQuery && filteredData.length !== rightData.length && (
<span className="text-primary ml-1">( {rightData.length} )</span>
)}
</div>
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 z-10 bg-gray-50">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
style={{
width: col.width ? `${col.width}px` : "auto",
textAlign: col.align || "left",
}}
>
{col.label}
</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">
{filteredData.map((item, idx) => {
const itemId = item.id || item.ID || idx;
return (
<tr key={itemId} className="hover:bg-accent transition-colors">
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</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>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// 목록 모드 (기존)
return filteredData.length > 0 ? (
<div className="space-y-2">
<div className="text-muted-foreground mb-2 text-xs">
{filteredData.length}
{rightSearchQuery && filteredData.length !== rightData.length && (
<span className="text-primary ml-1">( {rightData.length} )</span>
)}
</div>
{filteredData.map((item, index) => {
const itemId = item.id || item.ID || index;
const isExpanded = expandedRightItems.has(itemId);
// 우측 패널 표시 컬럼 설정 확인
const rightColumns = componentConfig.rightPanel?.columns;
let firstValues: [string, any, string][] = [];
let allValues: [string, any, string][] = [];
if (rightColumns && rightColumns.length > 0) {
// 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리)
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
firstValues = rightColumns
.slice(0, summaryCount)
.map((col) => {
// 🆕 엔티티 조인 컬럼 처리 (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) => {
// 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용)
const value = getEntityJoinValue(item, col.name);
return [col.name, value, col.label] as [string, any, string];
})
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
} else {
// 설정 없으면 모든 컬럼 표시 (기존 로직)
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
firstValues = Object.entries(item)
.filter(([key]) => !key.toLowerCase().includes("id"))
.slice(0, summaryCount)
2025-12-11 11:37:40 +09:00
.map(([key, value]) => [key, value, ""] as [string, any, string]);
allValues = Object.entries(item)
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
2025-12-11 11:37:40 +09:00
.map(([key, value]) => [key, value, ""] as [string, any, string]);
}
return (
<div
key={itemId}
2025-10-17 16:21:08 +09:00
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
>
2025-11-07 16:02:01 +09:00
{/* 요약 정보 */}
<div className="p-3">
<div className="flex items-start justify-between gap-2">
<div
2025-11-07 16:02:01 +09:00
className="min-w-0 flex-1 cursor-pointer"
onClick={() => toggleRightItemExpansion(itemId)}
>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{firstValues.map(([key, value, label], idx) => {
// 포맷 설정 및 볼드 설정 찾기
2025-12-11 11:37:40 +09:00
const colConfig = rightColumns?.find((c) => c.name === key);
const format = colConfig?.format;
const boldValue = colConfig?.bold ?? false;
2025-12-11 11:37:40 +09:00
// 🆕 포맷 적용 (날짜/숫자/카테고리)
const displayValue = formatCellValue(key, value, rightCategoryMappings, format);
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
2025-12-11 11:37:40 +09:00
return (
<div key={key} className="flex items-baseline gap-1">
{showLabel && (
<span className="text-muted-foreground text-xs font-medium whitespace-nowrap">
{label || getColumnLabel(key)}:
</span>
)}
2025-12-11 11:37:40 +09:00
<span
className={`text-foreground text-sm ${boldValue ? "font-semibold" : ""}`}
>
{displayValue}
</span>
</div>
);
})}
</div>
</div>
2025-11-07 16:02:01 +09:00
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
{/* 수정 버튼 */}
{!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && (
<Button
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
size="sm"
2025-11-07 16:02:01 +09:00
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="h-7"
2025-11-07 16:02:01 +09:00
>
2025-12-11 11:37:40 +09:00
<Pencil className="mr-1 h-3 w-3" />
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
</Button>
)}
2025-11-07 16:02:01 +09:00
{/* 삭제 버튼 */}
2025-12-11 11:37:40 +09:00
{!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
2025-11-07 16:02:01 +09:00
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
2025-12-11 11:37:40 +09:00
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
2025-11-07 16:02:01 +09:00
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
)}
{/* 확장/접기 버튼 */}
<button
onClick={() => toggleRightItemExpansion(itemId)}
className="rounded p-1 transition-colors hover:bg-gray-200"
2025-11-07 16:02:01 +09:00
>
{isExpanded ? (
<ChevronUp className="text-muted-foreground h-5 w-5" />
2025-11-07 16:02:01 +09:00
) : (
<ChevronDown className="text-muted-foreground h-5 w-5" />
2025-11-07 16:02:01 +09:00
)}
</button>
</div>
</div>
</div>
{/* 상세 정보 (확장 시 표시) */}
{isExpanded && (
2025-10-17 16:21:08 +09:00
<div className="bg-muted/50 border-t px-3 py-2">
<div className="mb-2 text-xs font-semibold"> </div>
<div className="bg-card overflow-auto rounded-md border">
<table className="w-full text-sm">
<tbody className="divide-border divide-y">
{allValues.map(([key, value, label]) => {
// 포맷 설정 찾기
2025-12-11 11:37:40 +09:00
const colConfig = rightColumns?.find((c) => c.name === key);
const format = colConfig?.format;
2025-12-11 11:37:40 +09:00
// 🆕 포맷 적용 (날짜/숫자/카테고리)
const displayValue = formatCellValue(key, value, rightCategoryMappings, format);
return (
<tr key={key} className="hover:bg-muted">
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
{label || getColumnLabel(key)}
</td>
<td className="text-foreground px-3 py-2 break-all">{displayValue}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
2025-12-11 11:37:40 +09:00
);
})}
</div>
) : (
<div className="text-muted-foreground py-8 text-center text-sm">
{rightSearchQuery ? (
<>
<p> .</p>
<p className="text-muted-foreground/70 mt-1 text-xs"> .</p>
</>
) : (
"관련 데이터가 없습니다."
)}
2025-10-15 17:25:38 +09:00
</div>
);
})()
) : (
// 상세 모드: 단일 객체를 상세 정보로 표시
(() => {
const rightColumns = componentConfig.rightPanel?.columns;
let displayEntries: [string, any, string][] = [];
if (rightColumns && rightColumns.length > 0) {
console.log("🔍 [디버깅] 상세 모드 표시 로직:");
console.log(" 📋 rightData 전체:", rightData);
console.log(" 📋 rightData keys:", Object.keys(rightData));
2025-12-11 11:37:40 +09:00
console.log(
" ⚙️ 설정된 컬럼:",
rightColumns.map((c) => `${c.name} (${c.label})`),
);
// 설정된 컬럼만 표시
displayEntries = rightColumns
.map((col) => {
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name)
let value = rightData[col.name];
console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`);
2025-12-11 11:37:40 +09:00
if (value === undefined && col.name.includes(".")) {
const columnName = col.name.split(".").pop();
value = rightData[columnName || ""];
console.log(` → 변환 후 "${columnName}" 접근 = ${value}`);
}
2025-12-11 11:37:40 +09:00
return [col.name, value, col.label] as [string, any, string];
})
.filter(([key, value]) => {
const filtered = value === null || value === undefined || value === "";
if (filtered) {
console.log(` ❌ 필터링됨: "${key}" (값: ${value})`);
}
return !filtered;
});
console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개");
} else {
// 설정 없으면 모든 컬럼 표시
displayEntries = Object.entries(rightData)
.filter(([_, value]) => value !== null && value !== undefined && value !== "")
.map(([key, value]) => [key, value, ""] as [string, any, string]);
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
}
return (
<div className="space-y-2">
{displayEntries.map(([key, value, label]) => (
<div key={key} className="bg-card rounded-lg border p-4 shadow-sm">
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
{label || getColumnLabel(key)}
</div>
<div className="text-sm">{String(value)}</div>
</div>
))}
</div>
);
})()
)
2025-10-15 17:25:38 +09:00
) : selectedLeftItem && isDesignMode ? (
// 디자인 모드: 샘플 데이터
<div className="space-y-4">
2025-10-17 16:21:08 +09:00
<div className="rounded-lg border p-4">
<h3 className="mb-2 font-medium">{selectedLeftItem.name} </h3>
2025-10-15 17:25:38 +09:00
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"> 1:</span>
2025-10-15 17:25:38 +09:00
<span className="font-medium"> 1</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> 2:</span>
2025-10-15 17:25:38 +09:00
<span className="font-medium"> 2</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> 3:</span>
2025-10-15 17:25:38 +09:00
<span className="font-medium"> 3</span>
</div>
</div>
</div>
</div>
) : (
// 선택 없음
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
2025-10-15 17:25:38 +09:00
<p className="mb-2"> </p>
<p className="text-xs"> </p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* 추가 모달 */}
<Dialog open={showAddModal} onOpenChange={setShowAddModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{addModalPanel === "left"
2025-11-07 15:21:44 +09:00
? `${componentConfig.leftPanel?.title} 추가`
: addModalPanel === "right"
? `${componentConfig.rightPanel?.title} 추가`
: `하위 ${componentConfig.leftPanel?.title} 추가`}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{addModalPanel === "left-item"
2025-11-07 15:21:44 +09:00
? "선택한 항목의 하위 항목을 추가합니다. 필수 항목을 입력해주세요."
: "새로운 데이터를 추가합니다. 필수 항목을 입력해주세요."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
2025-11-07 15:21:44 +09:00
{(() => {
// 어떤 컬럼들을 표시할지 결정
let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined;
2025-11-07 15:21:44 +09:00
if (addModalPanel === "left") {
modalColumns = componentConfig.leftPanel?.addModalColumns;
} else if (addModalPanel === "right") {
modalColumns = componentConfig.rightPanel?.addModalColumns;
} else if (addModalPanel === "left-item") {
modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns;
}
2025-11-07 15:21:44 +09:00
return modalColumns?.map((col, index) => {
// 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가
const isItemAddPreFilled =
addModalPanel === "left-item" &&
componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name &&
addModalFormData[col.name];
// 우측 패널 추가 시, 조인 컬럼(rightColumn)은 미리 채워져 있고 수정 불가
const isRightJoinPreFilled =
addModalPanel === "right" &&
componentConfig.rightPanel?.rightColumn === col.name &&
addModalFormData[col.name];
const isPreFilled = isItemAddPreFilled || isRightJoinPreFilled;
return (
<div key={index}>
<Label htmlFor={col.name} className="text-xs sm:text-sm">
{col.label} {col.required && <span className="text-destructive">*</span>}
{isPreFilled && <span className="ml-2 text-[10px] text-blue-600">( )</span>}
</Label>
<Input
id={col.name}
value={addModalFormData[col.name] || ""}
onChange={(e) => {
setAddModalFormData((prev) => ({
...prev,
[col.name]: e.target.value,
}));
}}
placeholder={`${col.label} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
required={col.required}
disabled={isPreFilled}
/>
</div>
);
2025-11-07 15:21:44 +09:00
});
})()}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowAddModal(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleAddModalSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
<Save className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-11-07 16:02:01 +09:00
{/* 수정 모달 */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editModalPanel === "left"
2025-11-07 16:02:01 +09:00
? `${componentConfig.leftPanel?.title} 수정`
: `${componentConfig.rightPanel?.title} 수정`}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{editModalItem &&
(() => {
// 좌측 패널 수정: leftColumn만 수정 가능
if (editModalPanel === "left") {
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
// leftColumn만 표시
if (!leftColumn || editModalFormData[leftColumn] === undefined) {
return <p className="text-muted-foreground text-sm"> .</p>;
}
2025-11-07 16:02:01 +09:00
return (
<div>
<Label htmlFor={`edit-${leftColumn}`} className="text-xs sm:text-sm">
{leftColumn}
2025-11-07 16:02:01 +09:00
</Label>
<Input
id={`edit-${leftColumn}`}
value={editModalFormData[leftColumn] || ""}
2025-11-07 16:02:01 +09:00
onChange={(e) => {
setEditModalFormData((prev) => ({
2025-11-07 16:02:01 +09:00
...prev,
[leftColumn]: e.target.value,
2025-11-07 16:02:01 +09:00
}));
}}
placeholder={`${leftColumn} 입력`}
2025-11-07 16:02:01 +09:00
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
);
}
// 우측 패널 수정: 우측 패널에 설정된 표시 컬럼들만
if (editModalPanel === "right") {
const rightColumns = componentConfig.rightPanel?.columns;
if (rightColumns && rightColumns.length > 0) {
// 설정된 컬럼만 표시
return rightColumns.map((col) => (
<div key={col.name}>
<Label htmlFor={`edit-${col.name}`} className="text-xs sm:text-sm">
{col.label || col.name}
2025-11-07 16:02:01 +09:00
</Label>
<Input
id={`edit-${col.name}`}
value={editModalFormData[col.name] || ""}
2025-11-07 16:02:01 +09:00
onChange={(e) => {
setEditModalFormData((prev) => ({
2025-11-07 16:02:01 +09:00
...prev,
[col.name]: e.target.value,
2025-11-07 16:02:01 +09:00
}));
}}
placeholder={`${col.label || col.name} 입력`}
2025-11-07 16:02:01 +09:00
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
));
} else {
// 설정이 없으면 모든 컬럼 표시 (민감한 필드 제외)
return Object.entries(editModalFormData)
.filter(([key]) => shouldShowField(key))
.map(([key, value]) => (
<div key={key}>
<Label htmlFor={`edit-${key}`} className="text-xs sm:text-sm">
{key}
</Label>
<Input
id={`edit-${key}`}
value={editModalFormData[key] || ""}
onChange={(e) => {
setEditModalFormData((prev) => ({
...prev,
[key]: e.target.value,
}));
}}
placeholder={`${key} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
));
}
2025-11-07 16:02:01 +09:00
}
return null;
})()}
2025-11-07 16:02:01 +09:00
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowEditModal(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleEditModalSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
2025-11-07 16:02:01 +09:00
<Save className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 모달 */}
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
?
<br /> .
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowDeleteModal(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Trash2 className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-10-15 17:25:38 +09:00
</div>
);
};
/**
* SplitPanelLayout
*/
export const SplitPanelLayoutWrapper: React.FC<SplitPanelLayoutComponentProps> = (props) => {
return <SplitPanelLayoutComponent {...props} />;
};