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

3283 lines
141 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

"use client";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ComponentRendererProps } from "../../types";
import { SplitPanelLayoutConfig } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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";
import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin";
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";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
}
/**
* SplitPanelLayout 컴포넌트
* 마스터-디테일 패턴의 좌우 분할 레이아웃
*/
export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
isPreview = false,
onClick,
...props
}) => {
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능)
const companyCode = (props as any).companyCode as string | undefined;
// 기본 설정값
const splitRatio = componentConfig.splitRatio || 30;
const resizable = componentConfig.resizable ?? true;
const minLeftWidth = componentConfig.minLeftWidth || 200;
const minRightWidth = componentConfig.minRightWidth || 300;
// 필드 표시 유틸리티 (하드코딩 제거, 동적으로 작동)
const shouldShowField = (fieldName: string): boolean => {
const lower = fieldName.toLowerCase();
// 기본 제외: id, 비밀번호, 토큰, 회사코드
if (lower === "id" || lower === "company_code" || lower === "company_name") return false;
if (lower.includes("password") || lower.includes("token")) return false;
// 나머지는 모두 표시!
return true;
};
// 🆕 엔티티 조인 컬럼명 변환 헬퍼
// "테이블명.컬럼명" 형식을 "원본컬럼_조인컬럼명" 형식으로 변환하여 데이터 접근
const getEntityJoinValue = useCallback(
(item: any, columnName: string, entityColumnMap?: Record<string, string>): any => {
// 🆕 백엔드가 제공하는 _label 필드 우선 사용
// 백엔드는 "표시 컬럼"이 설정된 경우 columnName_label을 자동 생성
const labelKey = `${columnName}_label`;
if (item[labelKey] !== undefined && item[labelKey] !== "" && item[labelKey] !== null) {
return item[labelKey];
}
// 직접 매칭 시도 (JOIN된 값이 없으면 원본 값 반환)
if (item[columnName] !== undefined) {
return item[columnName];
}
// "테이블명.컬럼명" 형식인 경우 (예: item_info.item_name)
if (columnName.includes(".")) {
const [tableName, fieldName] = columnName.split(".");
// 🔍 엔티티 조인 컬럼 값 추출
// 예: item_info.item_name, item_info.standard, item_info.unit
// 1⃣ 소스 컬럼 추론 (item_info → item_code, warehouse_info → warehouse_id 등)
const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id");
// 2⃣ 정확한 키 매핑 시도: 소스컬럼_필드명
// 예: item_code_item_name, item_code_standard, item_code_unit
const exactKey = `${inferredSourceColumn}_${fieldName}`;
if (item[exactKey] !== undefined) {
return item[exactKey];
}
// 🆕 2-1⃣ item_id 패턴 시도 (백엔드가 item_id_xxx 형식으로 반환하는 경우)
// 예: item_info.item_name → item_id_item_name
const idPatternKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
if (item[idPatternKey] !== undefined) {
return item[idPatternKey];
}
// 3⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용)
// 예: item_code_name (item_name의 별칭)
if (fieldName === "item_name" || fieldName === "name") {
const aliasKey = `${inferredSourceColumn}_name`;
if (item[aliasKey] !== undefined) {
return item[aliasKey];
}
// 🆕 item_id_name 패턴도 시도
const idAliasKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_name`;
if (item[idAliasKey] !== undefined) {
return item[idAliasKey];
}
}
// 4⃣ entityColumnMap에서 매핑 찾기 (화면 설정에서 지정된 경우)
if (entityColumnMap && entityColumnMap[tableName]) {
const sourceColumn = entityColumnMap[tableName];
const joinedColumnName = `${sourceColumn}_${fieldName}`;
if (item[joinedColumnName] !== undefined) {
return item[joinedColumnName];
}
}
// 5⃣ 테이블명_컬럼명 형식으로 시도
const underscoreKey = `${tableName}_${fieldName}`;
if (item[underscoreKey] !== undefined) {
return item[underscoreKey];
}
}
return undefined;
},
[],
);
// TableOptions Context
const { registerTable, unregisterTable } = useTableOptions();
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
const [leftColumnVisibility, setLeftColumnVisibility] = useState<ColumnVisibility[]>([]);
const [leftColumnOrder, setLeftColumnOrder] = useState<string[]>([]); // 🔧 컬럼 순서
const [leftGroupSumConfig, setLeftGroupSumConfig] = useState<GroupSumConfig | null>(null); // 🆕 그룹별 합산 설정
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
// 데이터 상태
const [leftData, setLeftData] = useState<any[]>([]);
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
const [leftSearchQuery, setLeftSearchQuery] = useState("");
const [rightSearchQuery, setRightSearchQuery] = useState("");
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
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 }>>
>({}); // 우측 카테고리 매핑
const { toast } = useToast();
// 추가 모달 상태
const [showAddModal, setShowAddModal] = useState(false);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
// 수정 모달 상태
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>>({});
// 삭제 확인 모달 상태
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null);
const [deleteModalItem, setDeleteModalItem] = useState<any>(null);
// 리사이저 드래그 상태
const [isDragging, setIsDragging] = useState(false);
const [leftWidth, setLeftWidth] = useState(splitRatio);
const containerRef = React.useRef<HTMLDivElement>(null);
// 🆕 SplitPanel Resize Context 연동 (버튼 등 외부 컴포넌트와 드래그 리사이즈 상태 공유)
const splitPanelContext = useSplitPanel();
const {
registerSplitPanel: ctxRegisterSplitPanel,
unregisterSplitPanel: ctxUnregisterSplitPanel,
updateSplitPanel: ctxUpdateSplitPanel,
} = splitPanelContext;
const splitPanelId = `split-panel-${component.id}`;
// 디버깅: Context 연결 상태 확인
console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
componentId: component.id,
splitPanelId,
hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
});
// Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행
const ctxRegisterRef = useRef(ctxRegisterSplitPanel);
const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel);
ctxRegisterRef.current = ctxRegisterSplitPanel;
ctxUnregisterRef.current = ctxUnregisterSplitPanel;
useEffect(() => {
// 컴포넌트의 위치와 크기 정보
const panelX = component.position?.x || 0;
const panelY = component.position?.y || 0;
const panelWidth = component.size?.width || component.style?.width || 800;
const panelHeight = component.size?.height || component.style?.height || 600;
const panelInfo = {
x: panelX,
y: panelY,
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
leftWidthPercent: splitRatio, // 초기값은 splitRatio 사용
initialLeftWidthPercent: splitRatio,
isDragging: false,
};
console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
splitPanelId,
panelInfo,
});
ctxRegisterRef.current(splitPanelId, panelInfo);
return () => {
console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
ctxUnregisterRef.current(splitPanelId);
};
// 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [splitPanelId]);
// 위치/크기 변경 시 Context 업데이트 (등록 후)
const ctxUpdateRef = useRef(ctxUpdateSplitPanel);
ctxUpdateRef.current = ctxUpdateSplitPanel;
useEffect(() => {
const panelX = component.position?.x || 0;
const panelY = component.position?.y || 0;
const panelWidth = component.size?.width || component.style?.width || 800;
const panelHeight = component.size?.height || component.style?.height || 600;
ctxUpdateRef.current(splitPanelId, {
x: panelX,
y: panelY,
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
});
}, [
splitPanelId,
component.position?.x,
component.position?.y,
component.size?.width,
component.size?.height,
component.style?.width,
component.style?.height,
]);
// leftWidth 변경 시 Context 업데이트
useEffect(() => {
ctxUpdateRef.current(splitPanelId, { leftWidthPercent: leftWidth });
}, [leftWidth, splitPanelId]);
// 드래그 상태 변경 시 Context 업데이트
// 이전 드래그 상태를 추적하여 드래그 종료 시점을 감지
const prevIsDraggingRef = useRef(false);
useEffect(() => {
const wasJustDragging = prevIsDraggingRef.current && !isDragging;
if (isDragging) {
// 드래그 시작 시: 현재 비율을 초기 비율로 저장
ctxUpdateRef.current(splitPanelId, {
isDragging: true,
initialLeftWidthPercent: leftWidth,
});
} else if (wasJustDragging) {
// 드래그 종료 시: 최종 비율을 초기 비율로 업데이트 (버튼 위치 고정)
ctxUpdateRef.current(splitPanelId, {
isDragging: false,
initialLeftWidthPercent: leftWidth,
});
console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", {
splitPanelId,
finalLeftWidthPercent: leftWidth,
});
}
prevIsDraggingRef.current = isDragging;
}, [isDragging, splitPanelId, leftWidth]);
// 🆕 그룹별 합산된 데이터 계산
const summedLeftData = useMemo(() => {
console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);
// 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환
if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) {
console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환");
return leftData;
}
const groupByColumn = leftGroupSumConfig.groupByColumn;
const groupMap = new Map<string, any>();
// 조인 컬럼인지 확인하고 실제 키 추론
const getActualKey = (columnName: string, item: any): string => {
if (columnName.includes(".")) {
const [refTable, fieldName] = columnName.split(".");
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
const exactKey = `${inferredSourceColumn}_${fieldName}`;
console.log("🔍 [그룹합산] 조인 컬럼 키 변환:", { columnName, exactKey, hasKey: item[exactKey] !== undefined });
if (item[exactKey] !== undefined) return exactKey;
if (fieldName === "item_name" || fieldName === "name") {
const aliasKey = `${inferredSourceColumn}_name`;
if (item[aliasKey] !== undefined) return aliasKey;
}
}
return columnName;
};
// 숫자 타입인지 확인하는 함수
const isNumericValue = (value: any): boolean => {
if (value === null || value === undefined || value === "") return false;
const num = parseFloat(String(value));
return !isNaN(num) && isFinite(num);
};
// 그룹핑 수행
leftData.forEach((item) => {
const actualKey = getActualKey(groupByColumn, item);
const groupValue = String(item[actualKey] || item[groupByColumn] || "");
// 원본 ID 추출 (id, ID, 또는 첫 번째 값)
const originalId = item.id || item.ID || Object.values(item)[0];
if (!groupMap.has(groupValue)) {
// 첫 번째 항목을 기준으로 초기화 + 원본 ID 배열 + 원본 데이터 배열
groupMap.set(groupValue, {
...item,
_groupCount: 1,
_originalIds: [originalId],
_originalItems: [item], // 🆕 원본 데이터 전체 저장
});
} else {
const existing = groupMap.get(groupValue);
existing._groupCount += 1;
existing._originalIds.push(originalId);
existing._originalItems.push(item); // 🆕 원본 데이터 추가
// 모든 키에 대해 숫자면 합산
Object.keys(item).forEach((key) => {
const value = item[key];
if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) {
const numValue = parseFloat(String(value));
const existingValue = parseFloat(String(existing[key] || 0));
existing[key] = existingValue + numValue;
}
});
groupMap.set(groupValue, existing);
}
});
const result = Array.from(groupMap.values());
console.log("🔗 [분할패널] 그룹별 합산 결과:", {
원본개수: leftData.length,
그룹개수: result.length,
그룹기준: groupByColumn,
});
return result;
}, [leftData, leftGroupSumConfig]);
// 컴포넌트 스타일
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
const getHeightValue = () => {
const height = component.style?.height;
if (!height) return "600px";
if (typeof height === "string") return height; // 이미 '540px' 형태
return `${height}px`; // 숫자면 px 추가
};
const componentStyle: React.CSSProperties = isPreview
? {
// 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용
position: "relative",
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
height: getHeightValue(),
border: "1px solid #e5e7eb",
}
: {
// 디자이너 모드: position absolute
position: "absolute",
left: `${component.style?.positionX || 0}px`,
top: `${component.style?.positionY || 0}px`,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 (그리드 기반)
height: getHeightValue(),
zIndex: component.style?.positionZ || 1,
cursor: isDesignMode ? "pointer" : "default",
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
};
// 계층 구조 빌드 함수 (트리 구조 유지)
const buildHierarchy = useCallback(
(items: any[]): any[] => {
if (!items || items.length === 0) return [];
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
if (!itemAddConfig) return items.map((item) => ({ ...item, children: [] })); // 계층 설정이 없으면 평면 목록
const { sourceColumn, parentColumn } = itemAddConfig;
if (!sourceColumn || !parentColumn) return items.map((item) => ({ ...item, children: [] }));
// 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 === "") {
// 최상위 항목
rootItems.push(currentItem);
} else {
// 부모가 있는 항목
const parentItem = itemMap.get(parentId);
if (parentItem) {
currentItem.level = parentItem.level + 1;
parentItem.children.push(currentItem);
} else {
// 부모를 찾을 수 없으면 최상위로 처리
rootItems.push(currentItem);
}
}
});
return rootItems;
},
[componentConfig.leftPanel?.itemAddConfig],
);
// 🔧 사용자 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]);
// 날짜 포맷팅 헬퍼 함수
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 }>>,
format?: {
type?: "number" | "currency" | "date" | "text";
thousandSeparator?: boolean;
decimalPlaces?: number;
prefix?: string;
suffix?: string;
dateFormat?: string;
},
) => {
if (value === null || value === undefined) return "-";
// 🆕 날짜 포맷 적용
if (format?.type === "date" || format?.dateFormat) {
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
}
// 🆕 숫자 포맷 적용
if (
format?.type === "number" ||
format?.type === "currency" ||
format?.thousandSeparator ||
format?.decimalPlaces !== undefined
) {
return formatNumberValue(value, format);
}
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
// 1. 전체 컬럼명 (예: "item_info.material")
// 2. 컬럼명만 (예: "material")
let mapping = categoryMappings[columnName];
if (!mapping && columnName.includes(".")) {
// 조인된 컬럼의 경우 컬럼명만으로 다시 시도
const simpleColumnName = columnName.split(".").pop() || columnName;
mapping = categoryMappings[simpleColumnName];
}
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>
);
}
// 🆕 자동 날짜 감지 (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);
},
[formatDateValue, formatNumberValue],
);
// 좌측 데이터 로드
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 또는 warehouse_info → warehouse_id)
// 기본: _info → _code, 백업: _info → _id
const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id");
// 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달)
const inferredSourceColumn = primarySourceColumn;
// 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼)
const existingJoin = additionalJoinColumns.find(
(j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn,
);
if (!existingJoin) {
// 새로운 조인 추가 (첫 번째 컬럼)
additionalJoinColumns.push({
sourceTable: leftTableName,
sourceColumn: inferredSourceColumn,
referenceTable: refTable,
joinAlias: `${inferredSourceColumn}_${refColumn}`,
});
sourceColumnMap[refTable] = inferredSourceColumn;
}
// 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등)
// 단, 첫 번째 컬럼과 다른 경우만
const existingAliases = additionalJoinColumns
.filter((j) => j.referenceTable === refTable)
.map((j) => j.joinAlias);
const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`;
if (!existingAliases.includes(newAlias)) {
additionalJoinColumns.push({
sourceTable: leftTableName,
sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn,
referenceTable: refTable,
joinAlias: newAlias,
});
}
}
});
console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns);
console.log("🔗 [분할패널] configuredColumns:", configuredColumns);
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: 1,
size: 100,
search: filters, // 필터 조건 전달
enableEntityJoin: true, // 엔티티 조인 활성화
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드
});
// 🔍 디버깅: 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");
});
}
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(result.data);
setLeftData(hierarchicalData);
} 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,
]);
// 우측 데이터 로드
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") {
// 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화)
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
// 🆕 엔티티 조인 API 사용
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: { id: primaryKey },
enableEntityJoin: true, // 엔티티 조인 활성화
size: 1,
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드
});
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
setRightData(detail);
} else if (relationshipType === "join") {
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
const keys = componentConfig.rightPanel?.relation?.keys;
const leftTable = componentConfig.leftPanel?.tableName;
// 🆕 그룹 합산된 항목인 경우: 원본 데이터들로 우측 패널 표시
if (leftItem._originalItems && leftItem._originalItems.length > 0) {
console.log("🔗 [분할패널] 그룹 합산 항목 - 원본 개수:", leftItem._originalItems.length);
// 정렬 기준 컬럼 (복합키의 leftColumn들)
const sortColumns = keys?.map((k: any) => k.leftColumn).filter(Boolean) || [];
console.log("🔗 [분할패널] 정렬 기준 컬럼:", sortColumns);
// 정렬 함수
const sortByKeys = (data: any[]) => {
if (sortColumns.length === 0) return data;
return [...data].sort((a, b) => {
for (const col of sortColumns) {
const aVal = String(a[col] || "");
const bVal = String(b[col] || "");
const cmp = aVal.localeCompare(bVal, "ko-KR");
if (cmp !== 0) return cmp;
}
return 0;
});
};
// 원본 데이터를 그대로 우측 패널에 표시 (이력 테이블과 동일 테이블인 경우)
if (leftTable === rightTableName) {
const sortedData = sortByKeys(leftItem._originalItems);
console.log("🔗 [분할패널] 동일 테이블 - 정렬된 원본 데이터:", sortedData.length);
setRightData(sortedData);
return;
}
// 다른 테이블인 경우: 원본 ID들로 조회
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const allResults: any[] = [];
// 각 원본 항목에 대해 조회
for (const originalItem of leftItem._originalItems) {
const searchConditions: Record<string, any> = {};
keys?.forEach((key: any) => {
if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = originalItem[key.leftColumn];
}
});
if (Object.keys(searchConditions).length > 0) {
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드
});
if (result.data) {
allResults.push(...result.data);
}
}
}
// 정렬 적용
const sortedResults = sortByKeys(allResults);
console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length);
setRightData(sortedResults);
return;
}
// 🆕 복합키 지원
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,
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드
});
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
// 추가 dataFilter 적용
let filteredData = result.data || [];
const dataFilter = componentConfig.rightPanel?.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
filteredData = filteredData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => {
const value = item[cond.column];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
return value === condValue;
case "notEquals":
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
default:
return true;
}
});
});
}
setRightData(filteredData);
} else {
// 단일키 (하위 호환성)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
if (leftColumn && rightColumn && leftTable) {
const leftValue = leftItem[leftColumn];
const joinedData = await dataApi.getJoinedData(
leftTable,
rightTableName,
leftColumn,
rightColumn,
leftValue,
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
true, // 🆕 Entity 조인 활성화
componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등)
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
);
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
}
}
}
} catch (error) {
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()); // 좌측 항목 변경 시 우측 확장 초기화
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);
});
}
},
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
);
// 우측 항목 확장/축소 토글
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) => {
// 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard 또는 item_id_standard)
let value: any;
if (columnName.includes(".")) {
// 조인 컬럼: getEntityJoinValue와 동일한 로직 적용
const [refTable, fieldName] = columnName.split(".");
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
// 정확한 키로 먼저 시도
const exactKey = `${inferredSourceColumn}_${fieldName}`;
value = item[exactKey];
// 🆕 item_id 패턴 시도
if (value === undefined) {
const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
value = item[idPatternKey];
}
// 기본 별칭 패턴 시도 (item_code_name 또는 item_id_name)
if (value === undefined && (fieldName === "item_name" || fieldName === "name")) {
const aliasKey = `${inferredSourceColumn}_name`;
value = item[aliasKey];
// item_id_name 패턴도 시도
if (value === undefined) {
const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`;
value = item[idAliasKey];
}
}
} else {
// 일반 컬럼
value = item[columnName];
}
if (value !== null && value !== undefined && value !== "") {
// _name 필드 우선 사용 (category/entity type)
const displayValue = item[`${columnName}_name`] || value;
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]);
// 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
useEffect(() => {
const loadRightCategoryMappings = async () => {
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
try {
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
// 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출
const rightColumns = componentConfig.rightPanel?.columns || [];
const tablesToLoad = new Set<string>([rightTableName]);
// 컬럼명에서 테이블명 추출 (예: "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);
}
});
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
// 각 테이블에 대해 카테고리 매핑 로드
for (const tableName of tablesToLoad) {
try {
// 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,
};
});
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
mappings[mappingKey] = valueMap;
// 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
// 기존 매핑이 있으면 병합, 없으면 새로 생성
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap);
console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]);
}
} catch (error) {
console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error);
}
}
} catch (error) {
console.error(`테이블 ${tableName} 컬럼 정보 조회 실패:`, error);
}
}
setRightCategoryMappings(mappings);
} catch (error) {
console.error("우측 카테고리 매핑 로드 실패:", error);
}
};
loadRightCategoryMappings();
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]);
// 항목 펼치기/접기 토글
const toggleExpand = useCallback((itemId: any) => {
setExpandedItems((prev) => {
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],
);
// 수정 버튼 핸들러
const handleEditClick = useCallback(
(panel: "left" | "right", item: any) => {
// 🆕 우측 패널 수정 버튼 설정 확인
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
if (modalScreenId) {
// 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || "";
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
let primaryKeyName = "id";
let primaryKeyValue: any;
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];
}
console.log("✅ 수정 모달 열기:", {
tableName: rightTableName,
primaryKeyName,
primaryKeyValue,
screenId: modalScreenId,
fullItem: item,
});
// modalDataStore에도 저장 (호환성 유지)
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(rightTableName, [item]);
});
// 🆕 groupByColumns 추출
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
groupByColumns,
editButtonConfig: componentConfig.rightPanel?.editButton,
hasGroupByColumns: groupByColumns.length > 0,
});
// 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),
}),
},
},
}),
);
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", {
screenId: modalScreenId,
editId: primaryKeyValue,
tableName: rightTableName,
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
});
return;
}
}
// 기존 자동 편집 모드 (인라인 편집 모달)
setEditModalPanel(panel);
setEditModalItem(item);
setEditModalFormData({ ...item });
setShowEditModal(true);
},
[componentConfig],
);
// 수정 모달 저장
const handleEditModalSave = useCallback(async () => {
const tableName =
editModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName;
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
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 });
// 프론트엔드 전용 필드 제거 (children, level 등)
const cleanData = { ...editModalFormData };
delete cleanData.children;
delete cleanData.level;
// 좌측 패널 수정 시, 조인 관계 정보 포함
const updatePayload: any = cleanData;
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);
}
const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload);
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 수정되었습니다.",
});
// 모달 닫기
setShowEditModal(false);
setEditModalFormData({});
setEditModalItem(null);
// 데이터 새로고침
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,
]);
// 삭제 버튼 핸들러
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);
}
if (!tableName || !primaryKey) {
toast({
title: "삭제 오류",
description: "테이블명 또는 Primary Key가 없습니다.",
variant: "destructive",
});
return;
}
try {
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
// 🔍 중복 제거 설정 디버깅
console.log("🔍 중복 제거 디버깅:", {
panel: deleteModalPanel,
dataFilter: componentConfig.rightPanel?.dataFilter,
deduplication: componentConfig.rightPanel?.dataFilter?.deduplication,
enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled,
});
let result;
// 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제
if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) {
const deduplication = componentConfig.rightPanel.dataFilter.deduplication;
const groupByColumn = deduplication.groupByColumn;
if (groupByColumn && deleteModalItem[groupByColumn]) {
const groupValue = deleteModalItem[groupByColumn];
console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`);
// groupByColumn 값으로 필터링하여 삭제
const filterConditions: Record<string, any> = {
[groupByColumn]: groupValue,
};
// 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등)
if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") {
const leftColumn = componentConfig.rightPanel.join.leftColumn;
const rightColumn = componentConfig.rightPanel.join.rightColumn;
filterConditions[rightColumn] = selectedLeftItem[leftColumn];
}
console.log("🗑️ 그룹 삭제 조건:", filterConditions);
// 그룹 삭제 API 호출
result = await dataApi.deleteGroupRecords(tableName, filterConditions);
} else {
// 단일 레코드 삭제
result = await dataApi.deleteRecord(tableName, primaryKey);
}
} else {
// 단일 레코드 삭제
result = await dataApi.deleteRecord(tableName, primaryKey);
}
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 삭제되었습니다.",
});
// 모달 닫기
setShowDeleteModal(false);
setDeleteModalItem(null);
// 데이터 새로고침
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);
// 외래키 제약조건 에러 처리
let errorMessage = "데이터 삭제 중 오류가 발생했습니다.";
if (error?.response?.data?.error?.includes("foreign key")) {
errorMessage = "이 데이터를 참조하는 다른 데이터가 있어 삭제할 수 없습니다.";
}
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
}
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]);
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
const handleItemAddClick = useCallback(
(item: any) => {
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
if (!itemAddConfig) {
toast({
title: "설정 오류",
description: "하위 항목 추가 설정이 없습니다.",
variant: "destructive",
});
return;
}
const { sourceColumn, parentColumn } = itemAddConfig;
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],
);
// 추가 모달 저장
const handleAddModalSave = useCallback(async () => {
// 테이블명과 모달 컬럼 결정
let tableName: string | undefined;
let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined;
const finalData = { ...addModalFormData };
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;
}
} 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({});
// 데이터 새로고침
if (addModalPanel === "left" || addModalPanel === "left-item") {
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
loadLeftData();
} 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]);
// 초기 데이터 로드
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]);
// 리사이저 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
if (!resizable) return;
setIsDragging(true);
e.preventDefault();
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
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) {
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);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.body.style.userSelect = "";
document.body.style.cursor = "";
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<div
ref={containerRef}
style={{
...(isPreview
? {
position: "relative",
height: `${component.style?.height || 600}px`,
border: "1px solid #e5e7eb",
}
: componentStyle),
display: "flex",
flexDirection: "row",
}}
onClick={(e) => {
if (isDesignMode) {
e.stopPropagation();
onClick?.(e);
}
}}
className="w-full overflow-hidden rounded-lg bg-white shadow-sm"
>
{/* 좌측 패널 */}
<div
style={{ width: `${leftWidth}%`, minWidth: isPreview ? "0" : `${minLeftWidth}px`, height: "100%" }}
className="border-border flex flex-shrink-0 flex-col border-r"
>
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader
className="flex-shrink-0 border-b"
style={{
height: componentConfig.leftPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.leftPanel?.panelHeaderHeight || 48,
padding: "0 1rem",
display: "flex",
alignItems: "center",
}}
>
<div className="flex w-full items-center justify-between">
<CardTitle className="text-base font-semibold">
{componentConfig.leftPanel?.title || "좌측 패널"}
</CardTitle>
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
</div>
</CardHeader>
{componentConfig.leftPanel?.showSearch && (
<div className="flex-shrink-0 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={leftSearchQuery}
onChange={(e) => setLeftSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
)}
<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>
</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>
</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",
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,
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 whitespace-nowrap"
style={{
width: col.width ? `${col.width}px` : "auto",
minWidth: "80px",
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" }}
>
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
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 whitespace-nowrap"
style={{
width: col.width ? `${col.width}px` : "auto",
minWidth: "80px",
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>
</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(
(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` }}
>
<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>
)}
</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>
</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>
)}
</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" />
</div>
)}
{/* 우측 패널 */}
<div
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px`, height: "100%" }}
className="flex flex-shrink-0 flex-col"
>
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader
className="flex-shrink-0 border-b"
style={{
height: componentConfig.rightPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,
padding: "0 1rem",
display: "flex",
alignItems: "center",
}}
>
<div className="flex w-full items-center justify-between">
<CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"}
</CardTitle>
{!isDesignMode && (
<div className="flex items-center gap-2">
{componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
</div>
)}
</div>
</CardHeader>
{componentConfig.rightPanel?.showSearch && (
<div className="flex-shrink-0 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={rightSearchQuery}
onChange={(e) => setRightSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터 */}
{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>
</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 whitespace-nowrap"
style={{
width: col.width ? `${col.width}px` : "auto",
minWidth: "80px",
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)
.map(([key, value]) => [key, value, ""] as [string, any, string]);
allValues = Object.entries(item)
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
.map(([key, value]) => [key, value, ""] as [string, any, string]);
}
return (
<div
key={itemId}
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
>
{/* 요약 정보 */}
<div className="p-3">
<div className="flex items-start justify-between gap-2">
<div
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) => {
// 포맷 설정 및 볼드 설정 찾기
const colConfig = rightColumns?.find((c) => c.name === key);
const format = colConfig?.format;
const boldValue = colConfig?.bold ?? false;
// 🆕 포맷 적용 (날짜/숫자/카테고리)
const displayValue = formatCellValue(key, value, rightCategoryMappings, format);
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
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>
)}
<span
className={`text-foreground text-sm ${boldValue ? "font-semibold" : ""}`}
>
{displayValue}
</span>
</div>
);
})}
</div>
</div>
<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"
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="h-7"
>
<Pencil className="mr-1 h-3 w-3" />
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
</Button>
)}
{/* 삭제 버튼 */}
{!isDesignMode && (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>
)}
{/* 확장/접기 버튼 */}
<button
onClick={() => toggleRightItemExpansion(itemId)}
className="rounded p-1 transition-colors hover:bg-gray-200"
>
{isExpanded ? (
<ChevronUp className="text-muted-foreground h-5 w-5" />
) : (
<ChevronDown className="text-muted-foreground h-5 w-5" />
)}
</button>
</div>
</div>
</div>
{/* 상세 정보 (확장 시 표시) */}
{isExpanded && (
<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]) => {
// 포맷 설정 찾기
const colConfig = rightColumns?.find((c) => c.name === key);
const format = colConfig?.format;
// 🆕 포맷 적용 (날짜/숫자/카테고리)
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>
);
})}
</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>
</>
) : (
"관련 데이터가 없습니다."
)}
</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));
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}`);
if (value === undefined && col.name.includes(".")) {
const columnName = col.name.split(".").pop();
value = rightData[columnName || ""];
console.log(` → 변환 후 "${columnName}" 접근 = ${value}`);
}
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>
);
})()
)
) : selectedLeftItem && isDesignMode ? (
// 디자인 모드: 샘플 데이터
<div className="space-y-4">
<div className="rounded-lg border p-4">
<h3 className="mb-2 font-medium">{selectedLeftItem.name} </h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"> 1:</span>
<span className="font-medium"> 1</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> 2:</span>
<span className="font-medium"> 2</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> 3:</span>
<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">
<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"
? `${componentConfig.leftPanel?.title} 추가`
: addModalPanel === "right"
? `${componentConfig.rightPanel?.title} 추가`
: `하위 ${componentConfig.leftPanel?.title} 추가`}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{addModalPanel === "left-item"
? "선택한 항목의 하위 항목을 추가합니다. 필수 항목을 입력해주세요."
: "새로운 데이터를 추가합니다. 필수 항목을 입력해주세요."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{(() => {
// 어떤 컬럼들을 표시할지 결정
let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined;
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;
}
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>
);
});
})()}
</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>
{/* 수정 모달 */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editModalPanel === "left"
? `${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>;
}
return (
<div>
<Label htmlFor={`edit-${leftColumn}`} className="text-xs sm:text-sm">
{leftColumn}
</Label>
<Input
id={`edit-${leftColumn}`}
value={editModalFormData[leftColumn] || ""}
onChange={(e) => {
setEditModalFormData((prev) => ({
...prev,
[leftColumn]: e.target.value,
}));
}}
placeholder={`${leftColumn} 입력`}
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}
</Label>
<Input
id={`edit-${col.name}`}
value={editModalFormData[col.name] || ""}
onChange={(e) => {
setEditModalFormData((prev) => ({
...prev,
[col.name]: e.target.value,
}));
}}
placeholder={`${col.label || col.name} 입력`}
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>
));
}
}
return null;
})()}
</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">
<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>
</div>
);
};
/**
* SplitPanelLayout 래퍼 컴포넌트
*/
export const SplitPanelLayoutWrapper: React.FC<SplitPanelLayoutComponentProps> = (props) => {
return <SplitPanelLayoutComponent {...props} />;
};