2025-10-15 17:25:38 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
import React, { useState, useCallback, useEffect, useMemo } 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";
|
2025-11-13 17:52:33 +09:00
|
|
|
import { Badge } from "@/components/ui/badge";
|
2025-11-13 17:55:10 +09:00
|
|
|
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";
|
2025-11-12 16:33:08 +09:00
|
|
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
2025-10-15 17:25:38 +09:00
|
|
|
import { useToast } from "@/hooks/use-toast";
|
2025-10-16 15:05:24 +09:00
|
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
2025-11-13 17:52:33 +09:00
|
|
|
import { apiClient } from "@/lib/api/client";
|
2025-11-13 17:55:10 +09:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
} from "@/components/ui/dialog";
|
2025-11-07 14:22:23 +09:00
|
|
|
import { Label } from "@/components/ui/label";
|
2025-11-12 10:48:24 +09:00
|
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
|
|
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
2025-11-12 16:33:08 +09:00
|
|
|
import { useAuth } from "@/hooks/useAuth";
|
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-11-20 12:19:27 +09:00
|
|
|
|
|
|
|
|
// 필드 표시 유틸리티 (하드코딩 제거, 동적으로 작동)
|
|
|
|
|
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;
|
|
|
|
|
};
|
2025-10-15 17:25:38 +09:00
|
|
|
|
2025-11-12 10:48:24 +09:00
|
|
|
// TableOptions Context
|
|
|
|
|
const { registerTable, unregisterTable } = useTableOptions();
|
|
|
|
|
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
|
|
|
|
|
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
|
|
|
|
|
const [leftColumnVisibility, setLeftColumnVisibility] = useState<ColumnVisibility[]>([]);
|
2025-11-12 16:33:08 +09:00
|
|
|
const [leftColumnOrder, setLeftColumnOrder] = useState<string[]>([]); // 🔧 컬럼 순서
|
2025-11-12 10:48:24 +09:00
|
|
|
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[]>([]);
|
2025-10-16 15:05:24 +09:00
|
|
|
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
2025-10-15 17:25:38 +09:00
|
|
|
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
2025-10-16 15:05:24 +09:00
|
|
|
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);
|
2025-10-16 15:05:24 +09:00
|
|
|
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
2025-11-07 15:21:44 +09:00
|
|
|
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
2025-11-11 11:37:26 +09:00
|
|
|
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
|
|
|
|
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
2025-11-13 17:55:10 +09:00
|
|
|
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();
|
|
|
|
|
|
2025-11-07 14:22:23 +09:00
|
|
|
// 추가 모달 상태
|
|
|
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
2025-11-07 15:21:44 +09:00
|
|
|
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
|
2025-11-07 14:22:23 +09:00
|
|
|
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
|
2025-11-13 17:55:10 +09:00
|
|
|
|
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-13 17:55:10 +09:00
|
|
|
|
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-11-07 14:22:23 +09:00
|
|
|
|
2025-10-15 17:25:38 +09:00
|
|
|
// 리사이저 드래그 상태
|
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
|
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
2025-10-16 15:05:24 +09:00
|
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
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
|
|
|
|
|
? {
|
2025-10-17 15:31:23 +09:00
|
|
|
// 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용
|
2025-10-16 18:16:57 +09:00
|
|
|
position: "relative",
|
2025-11-04 15:32:49 +09:00
|
|
|
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`,
|
2025-11-04 15:32:49 +09:00
|
|
|
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
|
|
|
// 계층 구조 빌드 함수 (트리 구조 유지)
|
2025-11-13 17:55:10 +09:00
|
|
|
const buildHierarchy = useCallback(
|
|
|
|
|
(items: any[]): any[] => {
|
|
|
|
|
if (!items || items.length === 0) return [];
|
2025-11-07 15:21:44 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
|
|
|
|
|
if (!itemAddConfig) return items.map((item) => ({ ...item, children: [] })); // 계층 설정이 없으면 평면 목록
|
2025-11-07 15:21:44 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
const { sourceColumn, parentColumn } = itemAddConfig;
|
|
|
|
|
if (!sourceColumn || !parentColumn) return items.map((item) => ({ ...item, children: [] }));
|
2025-11-07 15:21:44 +09:00
|
|
|
|
2025-11-13 17:55:10 +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);
|
2025-11-13 17:55:10 +09:00
|
|
|
} 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-13 17:55:10 +09:00
|
|
|
});
|
2025-11-07 15:21:44 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
return rootItems;
|
|
|
|
|
},
|
|
|
|
|
[componentConfig.leftPanel?.itemAddConfig],
|
|
|
|
|
);
|
2025-11-07 15:21:44 +09:00
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
// 🔧 사용자 ID 가져오기
|
|
|
|
|
const { userId: currentUserId } = useAuth();
|
|
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
// 🔄 필터를 searchValues 형식으로 변환
|
|
|
|
|
const searchValues = useMemo(() => {
|
|
|
|
|
if (!leftFilters || leftFilters.length === 0) return {};
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
const values: Record<string, any> = {};
|
2025-11-13 17:55:10 +09:00
|
|
|
leftFilters.forEach((filter) => {
|
|
|
|
|
if (filter.value !== undefined && filter.value !== null && filter.value !== "") {
|
2025-11-12 16:13:26 +09:00
|
|
|
values[filter.columnName] = {
|
|
|
|
|
value: filter.value,
|
2025-11-13 17:55:10 +09:00
|
|
|
operator: filter.operator || "contains",
|
2025-11-12 16:13:26 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return values;
|
|
|
|
|
}, [leftFilters]);
|
|
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
// 🔄 컬럼 가시성 및 순서 처리
|
2025-11-12 16:13:26 +09:00
|
|
|
const visibleLeftColumns = useMemo(() => {
|
|
|
|
|
const displayColumns = componentConfig.leftPanel?.columns || [];
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
if (displayColumns.length === 0) return [];
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
let columns = displayColumns;
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
// columnVisibility가 있으면 가시성 적용
|
|
|
|
|
if (leftColumnVisibility.length > 0) {
|
2025-11-13 17:55:10 +09:00
|
|
|
const visibilityMap = new Map(leftColumnVisibility.map((cv) => [cv.columnName, cv.visible]));
|
2025-11-12 16:33:08 +09:00
|
|
|
columns = columns.filter((col: any) => {
|
2025-11-13 17:55:10 +09:00
|
|
|
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
2025-11-12 16:13:26 +09:00
|
|
|
return visibilityMap.get(colName) !== false;
|
|
|
|
|
});
|
2025-11-12 16:33:08 +09:00
|
|
|
}
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
// 🔧 컬럼 순서 적용
|
|
|
|
|
if (leftColumnOrder.length > 0) {
|
|
|
|
|
const orderMap = new Map(leftColumnOrder.map((name, index) => [name, index]));
|
|
|
|
|
columns = [...columns].sort((a, b) => {
|
2025-11-13 17:55:10 +09:00
|
|
|
const aName = typeof a === "string" ? a : a.name || a.columnName;
|
|
|
|
|
const bName = typeof b === "string" ? b : b.name || b.columnName;
|
2025-11-12 16:33:08 +09:00
|
|
|
const aIndex = orderMap.get(aName) ?? 999;
|
|
|
|
|
const bIndex = orderMap.get(bName) ?? 999;
|
|
|
|
|
return aIndex - bIndex;
|
|
|
|
|
});
|
2025-11-12 16:13:26 +09:00
|
|
|
}
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
return columns;
|
|
|
|
|
}, [componentConfig.leftPanel?.columns, leftColumnVisibility, leftColumnOrder]);
|
2025-11-12 16:13:26 +09:00
|
|
|
|
|
|
|
|
// 🔄 데이터 그룹화
|
|
|
|
|
const groupedLeftData = useMemo(() => {
|
|
|
|
|
if (!leftGrouping || leftGrouping.length === 0 || leftData.length === 0) return [];
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
const grouped = new Map<string, any[]>();
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
leftData.forEach((item) => {
|
|
|
|
|
// 각 그룹 컬럼의 값을 조합하여 그룹 키 생성
|
2025-11-13 17:55:10 +09:00
|
|
|
const groupKey = leftGrouping
|
|
|
|
|
.map((col) => {
|
|
|
|
|
const value = item[col];
|
|
|
|
|
// null/undefined 처리
|
|
|
|
|
return value === null || value === undefined ? "(비어있음)" : String(value);
|
|
|
|
|
})
|
|
|
|
|
.join(" > ");
|
|
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
if (!grouped.has(groupKey)) {
|
|
|
|
|
grouped.set(groupKey, []);
|
|
|
|
|
}
|
|
|
|
|
grouped.get(groupKey)!.push(item);
|
|
|
|
|
});
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
return Array.from(grouped.entries()).map(([key, items]) => ({
|
|
|
|
|
groupKey: key,
|
|
|
|
|
items,
|
|
|
|
|
count: items.length,
|
|
|
|
|
}));
|
|
|
|
|
}, [leftData, leftGrouping]);
|
|
|
|
|
|
2025-11-13 17:52:33 +09:00
|
|
|
// 셀 값 포맷팅 함수 (카테고리 타입 처리)
|
2025-11-13 17:55:10 +09:00
|
|
|
const formatCellValue = useCallback(
|
|
|
|
|
(
|
|
|
|
|
columnName: string,
|
|
|
|
|
value: any,
|
|
|
|
|
categoryMappings: Record<string, Record<string, { label: string; color?: string }>>,
|
|
|
|
|
) => {
|
|
|
|
|
if (value === null || value === undefined) return "-";
|
|
|
|
|
|
|
|
|
|
// 카테고리 매핑이 있는지 확인
|
|
|
|
|
const mapping = categoryMappings[columnName];
|
|
|
|
|
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-11-13 17:52:33 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
// 일반 값
|
|
|
|
|
return String(value);
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
2025-11-13 17:52:33 +09:00
|
|
|
|
2025-10-15 17:25:38 +09:00
|
|
|
// 좌측 데이터 로드
|
|
|
|
|
const loadLeftData = useCallback(async () => {
|
|
|
|
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
|
|
|
|
if (!leftTableName || isDesignMode) return;
|
|
|
|
|
|
|
|
|
|
setIsLoadingLeft(true);
|
|
|
|
|
try {
|
2025-11-12 16:33:08 +09:00
|
|
|
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
|
2025-11-12 16:13:26 +09:00
|
|
|
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
2025-10-15 17:25:38 +09:00
|
|
|
page: 1,
|
|
|
|
|
size: 100,
|
2025-11-12 16:13:26 +09:00
|
|
|
search: filters, // 필터 조건 전달
|
2025-11-12 16:33:08 +09:00
|
|
|
enableEntityJoin: true, // 엔티티 조인 활성화
|
2025-11-13 17:06:41 +09:00
|
|
|
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
|
2025-11-12 16:33:08 +09:00
|
|
|
});
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-10 11:56:39 +09:00
|
|
|
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
|
|
|
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
|
|
|
|
if (leftColumn && result.data.length > 0) {
|
|
|
|
|
result.data.sort((a, b) => {
|
2025-11-13 17:55:10 +09:00
|
|
|
const aValue = String(a[leftColumn] || "");
|
|
|
|
|
const bValue = String(b[leftColumn] || "");
|
|
|
|
|
return aValue.localeCompare(bValue, "ko-KR");
|
2025-11-10 11:56:39 +09:00
|
|
|
});
|
|
|
|
|
}
|
2025-11-13 17:55:10 +09:00
|
|
|
|
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);
|
|
|
|
|
}
|
2025-11-13 17:55:10 +09:00
|
|
|
}, [
|
|
|
|
|
componentConfig.leftPanel?.tableName,
|
|
|
|
|
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-11-20 10:23:54 +09:00
|
|
|
// 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화)
|
2025-10-15 17:25:38 +09:00
|
|
|
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
2025-11-20 10:23:54 +09:00
|
|
|
|
|
|
|
|
// 🆕 엔티티 조인 API 사용
|
|
|
|
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
|
|
|
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
|
|
|
|
search: { id: primaryKey },
|
|
|
|
|
enableEntityJoin: true, // 엔티티 조인 활성화
|
|
|
|
|
size: 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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-10-16 15:05:24 +09:00
|
|
|
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
2025-10-15 17:25:38 +09:00
|
|
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
|
|
|
|
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
|
|
|
|
const leftTable = componentConfig.leftPanel?.tableName;
|
|
|
|
|
|
|
|
|
|
if (leftColumn && rightColumn && leftTable) {
|
|
|
|
|
const leftValue = leftItem[leftColumn];
|
|
|
|
|
const joinedData = await dataApi.getJoinedData(
|
|
|
|
|
leftTable,
|
|
|
|
|
rightTableName,
|
|
|
|
|
leftColumn,
|
|
|
|
|
rightColumn,
|
|
|
|
|
leftValue,
|
2025-11-13 17:06:41 +09:00
|
|
|
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
|
2025-11-20 10:23:54 +09:00
|
|
|
true, // 🆕 Entity 조인 활성화
|
|
|
|
|
componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등)
|
|
|
|
|
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
2025-10-15 17:25:38 +09:00
|
|
|
);
|
2025-10-16 15:05:24 +09:00
|
|
|
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);
|
2025-10-16 15:05:24 +09:00
|
|
|
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
2025-10-15 17:25:38 +09:00
|
|
|
loadRightData(item);
|
2025-11-18 16:12:47 +09:00
|
|
|
|
|
|
|
|
// 🆕 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
|
|
|
},
|
2025-11-18 16:12:47 +09:00
|
|
|
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
|
2025-10-15 17:25:38 +09:00
|
|
|
);
|
|
|
|
|
|
2025-10-16 15:05:24 +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],
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
// 🔧 컬럼의 고유값 가져오기 함수
|
2025-11-13 17:55:10 +09:00
|
|
|
const getLeftColumnUniqueValues = useCallback(
|
|
|
|
|
async (columnName: string) => {
|
|
|
|
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
|
|
|
|
if (!leftTableName || leftData.length === 0) return [];
|
2025-11-12 16:33:08 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
// 현재 로드된 데이터에서 고유값 추출
|
|
|
|
|
const uniqueValues = new Set<string>();
|
|
|
|
|
|
|
|
|
|
leftData.forEach((item) => {
|
|
|
|
|
const 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],
|
|
|
|
|
);
|
2025-11-12 16:33:08 +09:00
|
|
|
|
2025-11-12 10:48:24 +09:00
|
|
|
// 좌측 테이블 등록 (Context에 등록)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
|
|
|
|
if (!leftTableName || isDesignMode) return;
|
|
|
|
|
|
|
|
|
|
const leftTableId = `split-panel-left-${component.id}`;
|
2025-11-12 16:13:26 +09:00
|
|
|
// 🔧 화면에 표시되는 컬럼 사용 (columns 속성)
|
|
|
|
|
const configuredColumns = componentConfig.leftPanel?.columns || [];
|
2025-11-13 17:55:10 +09:00
|
|
|
const displayColumns = configuredColumns
|
|
|
|
|
.map((col: any) => {
|
|
|
|
|
if (typeof col === "string") return col;
|
|
|
|
|
return col.columnName || col.name || col;
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
// 화면에 설정된 컬럼이 없으면 등록하지 않음
|
2025-11-12 16:05:45 +09:00
|
|
|
if (displayColumns.length === 0) return;
|
2025-11-12 10:48:24 +09:00
|
|
|
|
2025-11-12 16:05:45 +09:00
|
|
|
// 테이블명이 있으면 등록
|
|
|
|
|
registerTable({
|
|
|
|
|
tableId: leftTableId,
|
|
|
|
|
label: `${component.title || "분할 패널"} (좌측)`,
|
|
|
|
|
tableName: leftTableName,
|
|
|
|
|
columns: displayColumns.map((col: string) => ({
|
|
|
|
|
columnName: col,
|
|
|
|
|
columnLabel: leftColumnLabels[col] || col,
|
|
|
|
|
inputType: "text",
|
|
|
|
|
visible: true,
|
|
|
|
|
width: 150,
|
|
|
|
|
sortable: true,
|
|
|
|
|
filterable: true,
|
|
|
|
|
})),
|
|
|
|
|
onFilterChange: setLeftFilters,
|
|
|
|
|
onGroupChange: setLeftGrouping,
|
|
|
|
|
onColumnVisibilityChange: setLeftColumnVisibility,
|
2025-11-12 16:33:08 +09:00
|
|
|
onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가
|
|
|
|
|
getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가
|
2025-11-12 16:05:45 +09:00
|
|
|
});
|
2025-11-12 10:48:24 +09:00
|
|
|
|
2025-11-12 16:05:45 +09:00
|
|
|
return () => unregisterTable(leftTableId);
|
2025-11-13 17:55:10 +09:00
|
|
|
}, [
|
|
|
|
|
component.id,
|
|
|
|
|
componentConfig.leftPanel?.tableName,
|
|
|
|
|
componentConfig.leftPanel?.columns,
|
|
|
|
|
leftColumnLabels,
|
|
|
|
|
component.title,
|
|
|
|
|
isDesignMode,
|
|
|
|
|
getLeftColumnUniqueValues,
|
|
|
|
|
]);
|
2025-11-12 10:48:24 +09:00
|
|
|
|
2025-11-12 15:54:48 +09:00
|
|
|
// 우측 테이블은 검색 컴포넌트 등록 제외 (좌측 마스터 테이블만 검색 가능)
|
|
|
|
|
// 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]);
|
2025-11-12 10:48:24 +09:00
|
|
|
|
2025-11-11 11:37:26 +09:00
|
|
|
// 좌측 테이블 컬럼 라벨 로드
|
|
|
|
|
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]);
|
|
|
|
|
|
2025-10-16 15:05:24 +09:00
|
|
|
// 우측 테이블 컬럼 정보 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const loadRightTableColumns = async () => {
|
|
|
|
|
const rightTableName = componentConfig.rightPanel?.tableName;
|
|
|
|
|
if (!rightTableName || isDesignMode) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
|
|
|
|
|
setRightTableColumns(columnsResponse || []);
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-11 11:37:26 +09:00
|
|
|
// 우측 컬럼 라벨도 함께 로드
|
|
|
|
|
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);
|
2025-10-16 15:05:24 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadRightTableColumns();
|
|
|
|
|
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
|
|
|
|
|
2025-11-13 17:52:33 +09:00
|
|
|
// 좌측 테이블 카테고리 매핑 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const loadLeftCategoryMappings = async () => {
|
|
|
|
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
|
|
|
|
if (!leftTableName || isDesignMode) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 1. 컬럼 메타 정보 조회
|
|
|
|
|
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
|
2025-11-13 17:55:10 +09:00
|
|
|
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
|
2025-11-13 17:52:33 +09:00
|
|
|
|
|
|
|
|
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 {
|
2025-11-13 17:55:10 +09:00
|
|
|
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`);
|
2025-11-13 17:52:33 +09:00
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
// 1. 컬럼 메타 정보 조회
|
|
|
|
|
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
|
2025-11-13 17:55:10 +09:00
|
|
|
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
|
2025-11-13 17:52:33 +09:00
|
|
|
|
|
|
|
|
if (categoryColumns.length === 0) {
|
|
|
|
|
setRightCategoryMappings({});
|
|
|
|
|
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 {
|
2025-11-13 17:55:10 +09:00
|
|
|
const response = await apiClient.get(`/table-categories/${rightTableName}/${columnName}/values`);
|
2025-11-13 17:52:33 +09:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setRightCategoryMappings(mappings);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("우측 카테고리 매핑 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadRightCategoryMappings();
|
|
|
|
|
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
|
|
|
|
|
2025-11-07 15:21:44 +09:00
|
|
|
// 항목 펼치기/접기 토글
|
|
|
|
|
const toggleExpand = useCallback((itemId: any) => {
|
2025-11-13 17:55:10 +09:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-11-07 14:22:23 +09:00
|
|
|
// 추가 버튼 핸들러
|
2025-11-13 17:55:10 +09:00
|
|
|
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 14:22:23 +09:00
|
|
|
|
2025-11-07 16:02:01 +09:00
|
|
|
// 수정 버튼 핸들러
|
2025-11-20 10:23:54 +09:00
|
|
|
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],
|
|
|
|
|
);
|
2025-11-07 16:02:01 +09:00
|
|
|
|
|
|
|
|
// 수정 모달 저장
|
|
|
|
|
const handleEditModalSave = useCallback(async () => {
|
2025-11-13 17:55:10 +09:00
|
|
|
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-13 17:55:10 +09:00
|
|
|
|
2025-11-07 16:02:01 +09:00
|
|
|
// 프론트엔드 전용 필드 제거 (children, level 등)
|
|
|
|
|
const cleanData = { ...editModalFormData };
|
|
|
|
|
delete cleanData.children;
|
|
|
|
|
delete cleanData.level;
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 16:02:01 +09:00
|
|
|
// 좌측 패널 수정 시, 조인 관계 정보 포함
|
2025-11-13 17:55:10 +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-13 17:55:10 +09:00
|
|
|
|
2025-11-07 16:02:01 +09:00
|
|
|
const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload);
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "성공",
|
|
|
|
|
description: "데이터가 성공적으로 수정되었습니다.",
|
|
|
|
|
});
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 16:02:01 +09:00
|
|
|
// 모달 닫기
|
|
|
|
|
setShowEditModal(false);
|
|
|
|
|
setEditModalFormData({});
|
|
|
|
|
setEditModalItem(null);
|
2025-11-13 17:55:10 +09:00
|
|
|
|
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",
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-11-13 17:55:10 +09:00
|
|
|
}, [
|
|
|
|
|
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 () => {
|
2025-11-07 18:20:24 +09:00
|
|
|
// 우측 패널 삭제 시 중계 테이블 확인
|
2025-11-13 17:55:10 +09:00
|
|
|
let tableName =
|
|
|
|
|
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName;
|
|
|
|
|
|
2025-11-07 18:20:24 +09:00
|
|
|
// 우측 패널 + 중계 테이블 모드인 경우
|
|
|
|
|
if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) {
|
|
|
|
|
tableName = componentConfig.rightPanel.addConfig.targetTable;
|
|
|
|
|
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
|
|
|
|
|
}
|
2025-11-13 17:55:10 +09:00
|
|
|
|
|
|
|
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
2025-11-07 18:20:24 +09:00
|
|
|
let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID;
|
|
|
|
|
|
|
|
|
|
// 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리)
|
2025-11-13 17:55:10 +09:00
|
|
|
if (deleteModalItem && typeof deleteModalItem === "object") {
|
2025-11-07 18:20:24 +09:00
|
|
|
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-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-13 17:55:10 +09:00
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2025-11-07 16:02:01 +09:00
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "성공",
|
|
|
|
|
description: "데이터가 성공적으로 삭제되었습니다.",
|
|
|
|
|
});
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 16:02:01 +09:00
|
|
|
// 모달 닫기
|
|
|
|
|
setShowDeleteModal(false);
|
|
|
|
|
setDeleteModalItem(null);
|
2025-11-13 17:55:10 +09:00
|
|
|
|
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-13 17:55:10 +09:00
|
|
|
|
2025-11-07 16:02:01 +09:00
|
|
|
// 외래키 제약조건 에러 처리
|
|
|
|
|
let errorMessage = "데이터 삭제 중 오류가 발생했습니다.";
|
|
|
|
|
if (error?.response?.data?.error?.includes("foreign key")) {
|
|
|
|
|
errorMessage = "이 데이터를 참조하는 다른 데이터가 있어 삭제할 수 없습니다.";
|
|
|
|
|
}
|
2025-11-13 17:55:10 +09:00
|
|
|
|
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
|
|
|
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
|
2025-11-13 17:55:10 +09:00
|
|
|
const handleItemAddClick = useCallback(
|
|
|
|
|
(item: any) => {
|
|
|
|
|
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
|
2025-11-07 15:21:44 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
if (!itemAddConfig) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "설정 오류",
|
|
|
|
|
description: "하위 항목 추가 설정이 없습니다.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-07 15:21:44 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
const { sourceColumn, parentColumn } = itemAddConfig;
|
2025-11-07 15:21:44 +09:00
|
|
|
|
2025-11-13 17:55:10 +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
|
|
|
|
2025-11-07 14:22:23 +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;
|
2025-11-13 17:55:10 +09:00
|
|
|
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") {
|
2025-11-07 18:20:24 +09:00
|
|
|
// 우측 패널: 중계 테이블 설정이 있는지 확인
|
|
|
|
|
const addConfig = componentConfig.rightPanel?.addConfig;
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 18:20:24 +09:00
|
|
|
if (addConfig?.targetTable) {
|
|
|
|
|
// 중계 테이블 모드
|
|
|
|
|
tableName = addConfig.targetTable;
|
|
|
|
|
modalColumns = componentConfig.rightPanel?.addModalColumns;
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 18:20:24 +09:00
|
|
|
// 좌측 패널에서 선택된 값 자동 채우기
|
|
|
|
|
if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) {
|
|
|
|
|
const leftValue = selectedLeftItem[addConfig.leftPanelColumn];
|
|
|
|
|
finalData[addConfig.targetColumn] = leftValue;
|
|
|
|
|
console.log(`🔗 좌측 패널 값 자동 채움: ${addConfig.targetColumn} = ${leftValue}`);
|
|
|
|
|
}
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 18:20:24 +09:00
|
|
|
// 자동 채움 컬럼 추가
|
|
|
|
|
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;
|
|
|
|
|
}
|
2025-11-07 14:22:23 +09:00
|
|
|
|
|
|
|
|
if (!tableName) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "테이블 오류",
|
|
|
|
|
description: "테이블명이 설정되지 않았습니다.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 필수 필드 검증
|
2025-11-13 17:55:10 +09:00
|
|
|
const requiredFields = (modalColumns || []).filter((col) => col.required);
|
2025-11-07 14:22:23 +09:00
|
|
|
for (const field of requiredFields) {
|
|
|
|
|
if (!addModalFormData[field.name]) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "입력 오류",
|
|
|
|
|
description: `${field.label}은(는) 필수 입력 항목입니다.`,
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-07 18:20:24 +09:00
|
|
|
console.log("📝 데이터 추가:", { tableName, data: finalData });
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 18:20:24 +09:00
|
|
|
const result = await dataApi.createRecord(tableName, finalData);
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 14:22:23 +09:00
|
|
|
if (result.success) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "성공",
|
|
|
|
|
description: "데이터가 성공적으로 추가되었습니다.",
|
|
|
|
|
});
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 14:22:23 +09:00
|
|
|
// 모달 닫기
|
|
|
|
|
setShowAddModal(false);
|
|
|
|
|
setAddModalFormData({});
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 14:22:23 +09:00
|
|
|
// 데이터 새로고침
|
2025-11-07 15:21:44 +09:00
|
|
|
if (addModalPanel === "left" || addModalPanel === "left-item") {
|
|
|
|
|
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
|
2025-11-07 14:22:23 +09:00
|
|
|
loadLeftData();
|
2025-11-07 15:21:44 +09:00
|
|
|
} else if (addModalPanel === "right" && selectedLeftItem) {
|
|
|
|
|
// 우측 패널 데이터 새로고침
|
2025-11-07 14:22:23 +09:00
|
|
|
loadRightData(selectedLeftItem);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
toast({
|
|
|
|
|
title: "저장 실패",
|
|
|
|
|
description: result.message || "데이터 추가에 실패했습니다.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("데이터 추가 오류:", error);
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 14:22:23 +09:00
|
|
|
// 에러 메시지 추출
|
|
|
|
|
let errorMessage = "데이터 추가 중 오류가 발생했습니다.";
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 14:22:23 +09:00
|
|
|
if (error?.response?.data) {
|
|
|
|
|
const responseData = error.response.data;
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 14:22:23 +09:00
|
|
|
// 백엔드에서 반환한 에러 메시지 확인
|
|
|
|
|
if (responseData.error) {
|
|
|
|
|
// 중복 키 에러 처리
|
|
|
|
|
if (responseData.error.includes("duplicate key")) {
|
|
|
|
|
errorMessage = "이미 존재하는 값입니다. 다른 값을 입력해주세요.";
|
2025-11-13 17:55:10 +09:00
|
|
|
}
|
2025-11-07 14:22:23 +09:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 14:22:23 +09:00
|
|
|
toast({
|
|
|
|
|
title: "오류",
|
|
|
|
|
description: errorMessage,
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, [addModalPanel, componentConfig, addModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]);
|
|
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
// 🔧 좌측 컬럼 가시성 설정 저장 및 불러오기
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
|
|
|
|
if (leftTableName && currentUserId) {
|
|
|
|
|
// localStorage에서 저장된 설정 불러오기
|
|
|
|
|
const storageKey = `table_column_visibility_${leftTableName}_${currentUserId}`;
|
|
|
|
|
const savedSettings = localStorage.getItem(storageKey);
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
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;
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
if (leftColumnVisibility.length > 0 && leftTableName && currentUserId) {
|
|
|
|
|
// 순서 업데이트
|
2025-11-13 17:55:10 +09:00
|
|
|
const newOrder = leftColumnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외
|
|
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
setLeftColumnOrder(newOrder);
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-12 16:33:08 +09:00
|
|
|
// 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]);
|
|
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
// 🔄 필터 변경 시 데이터 다시 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
|
|
|
|
loadLeftData();
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [leftFilters]);
|
|
|
|
|
|
2025-11-13 17:42:20 +09:00
|
|
|
// 🆕 전역 테이블 새로고침 이벤트 리스너
|
|
|
|
|
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) => {
|
2025-10-16 15:05:24 +09:00
|
|
|
if (!isDragging || !containerRef.current) return;
|
2025-10-15 17:25:38 +09:00
|
|
|
|
2025-10-16 15:05:24 +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) {
|
2025-10-16 15:05:24 +09:00
|
|
|
// 드래그 중에는 텍스트 선택 방지
|
|
|
|
|
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-16 15:05:24 +09:00
|
|
|
|
2025-10-15 17:25:38 +09:00
|
|
|
return () => {
|
2025-10-16 15:05:24 +09:00
|
|
|
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
|
2025-10-16 15:05:24 +09:00
|
|
|
ref={containerRef}
|
2025-11-05 16:18:00 +09:00
|
|
|
style={{
|
|
|
|
|
...(isPreview
|
2025-10-17 15:31:23 +09:00
|
|
|
? {
|
|
|
|
|
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 && (
|
2025-11-13 17:55:10 +09:00
|
|
|
<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">
|
2025-11-13 17:55:10 +09:00
|
|
|
<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">
|
2025-11-11 11:37:26 +09:00
|
|
|
{/* 좌측 데이터 목록/테이블 */}
|
|
|
|
|
{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">
|
2025-11-13 17:55:10 +09:00
|
|
|
<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>
|
2025-11-11 11:37:26 +09:00
|
|
|
</tr>
|
2025-11-13 17:55:10 +09:00
|
|
|
<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>
|
2025-11-11 11:37:26 +09:00
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
2025-10-15 17:25:38 +09:00
|
|
|
</div>
|
2025-11-11 11:37:26 +09:00
|
|
|
) : 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>
|
2025-11-11 11:37:26 +09:00
|
|
|
) : (
|
|
|
|
|
(() => {
|
2025-11-12 16:13:26 +09:00
|
|
|
// 🔧 로컬 검색 필터 적용
|
2025-11-11 11:37:26 +09:00
|
|
|
const filteredData = leftSearchQuery
|
|
|
|
|
? leftData.filter((item) => {
|
|
|
|
|
const searchLower = leftSearchQuery.toLowerCase();
|
|
|
|
|
return Object.entries(item).some(([key, value]) => {
|
|
|
|
|
if (value === null || value === undefined) return false;
|
|
|
|
|
return String(value).toLowerCase().includes(searchLower);
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
: leftData;
|
|
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
// 🔧 가시성 처리된 컬럼 사용
|
2025-11-13 17:55:10 +09:00
|
|
|
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",
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
: 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-11-11 11:37:26 +09:00
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
// 🔧 그룹화된 데이터 렌더링
|
|
|
|
|
if (groupedLeftData.length > 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="overflow-auto">
|
|
|
|
|
{groupedLeftData.map((group, groupIdx) => (
|
|
|
|
|
<div key={groupIdx} className="mb-4">
|
2025-11-13 17:55:10 +09:00
|
|
|
<div className="bg-gray-100 px-3 py-2 text-sm font-semibold">
|
2025-11-12 16:13:26 +09:00
|
|
|
{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}
|
2025-11-13 17:55:10 +09:00
|
|
|
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",
|
|
|
|
|
}}
|
2025-11-12 16:13:26 +09:00
|
|
|
>
|
|
|
|
|
{col.label}
|
|
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-gray-200 bg-white">
|
|
|
|
|
{group.items.map((item, idx) => {
|
2025-11-13 17:55:10 +09:00
|
|
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
2025-11-12 16:13:26 +09:00
|
|
|
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
2025-11-13 17:55:10 +09:00
|
|
|
const isSelected =
|
|
|
|
|
selectedLeftItem &&
|
|
|
|
|
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
|
|
|
|
|
2025-11-12 16:13:26 +09:00
|
|
|
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}
|
2025-11-13 17:55:10 +09:00
|
|
|
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
2025-11-12 16:13:26 +09:00
|
|
|
style={{ textAlign: col.align || "left" }}
|
|
|
|
|
>
|
2025-11-13 17:52:33 +09:00
|
|
|
{formatCellValue(col.name, item[col.name], leftCategoryMappings)}
|
2025-11-12 16:13:26 +09:00
|
|
|
</td>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 🔧 일반 테이블 렌더링 (그룹화 없음)
|
2025-11-11 11:37:26 +09:00
|
|
|
return (
|
|
|
|
|
<div className="overflow-auto">
|
|
|
|
|
<table className="min-w-full divide-y divide-gray-200">
|
2025-11-13 17:55:10 +09:00
|
|
|
<thead className="sticky top-0 z-10 bg-gray-50">
|
2025-11-11 11:37:26 +09:00
|
|
|
<tr>
|
|
|
|
|
{columnsToShow.map((col, idx) => (
|
|
|
|
|
<th
|
|
|
|
|
key={idx}
|
2025-11-13 17:55:10 +09:00
|
|
|
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",
|
|
|
|
|
}}
|
2025-11-11 11:37:26 +09:00
|
|
|
>
|
|
|
|
|
{col.label}
|
|
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-gray-200 bg-white">
|
|
|
|
|
{filteredData.map((item, idx) => {
|
2025-11-13 17:55:10 +09:00
|
|
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
2025-11-11 11:37:26 +09:00
|
|
|
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
2025-11-13 17:55:10 +09:00
|
|
|
const isSelected =
|
|
|
|
|
selectedLeftItem &&
|
|
|
|
|
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
|
|
|
|
|
2025-11-11 11:37:26 +09:00
|
|
|
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}
|
2025-11-13 17:55:10 +09:00
|
|
|
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
2025-11-11 11:37:26 +09:00
|
|
|
style={{ textAlign: col.align || "left" }}
|
|
|
|
|
>
|
2025-11-13 17:52:33 +09:00
|
|
|
{formatCellValue(col.name, item[col.name], leftCategoryMappings)}
|
2025-11-11 11:37:26 +09:00
|
|
|
</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>
|
2025-11-11 11:37:26 +09:00
|
|
|
) : (
|
2025-11-13 17:55:10 +09:00
|
|
|
(() => {
|
|
|
|
|
// 검색 필터링 (클라이언트 사이드)
|
|
|
|
|
const filteredLeftData = leftSearchQuery
|
|
|
|
|
? leftData.filter((item) => {
|
|
|
|
|
const searchLower = leftSearchQuery.toLowerCase();
|
|
|
|
|
return Object.entries(item).some(([key, value]) => {
|
|
|
|
|
if (value === null || value === undefined) return false;
|
|
|
|
|
return String(value).toLowerCase().includes(searchLower);
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
: leftData;
|
2025-10-16 15:05:24 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
// 재귀 렌더링 함수
|
|
|
|
|
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;
|
2025-11-07 11:51:44 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
// 조인에 사용하는 leftColumn을 필수로 표시
|
|
|
|
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
|
|
|
|
let displayFields: { label: string; value: any }[] = [];
|
2025-11-07 11:51:44 +09:00
|
|
|
|
|
|
|
|
// 디버그 로그
|
|
|
|
|
if (index === 0) {
|
|
|
|
|
console.log("🔍 좌측 패널 표시 로직:");
|
|
|
|
|
console.log(" - leftColumn (조인 키):", leftColumn);
|
|
|
|
|
console.log(" - item keys:", Object.keys(item));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (leftColumn) {
|
|
|
|
|
// 조인 모드: leftColumn 값을 첫 번째로 표시 (필수)
|
|
|
|
|
displayFields.push({
|
|
|
|
|
label: leftColumn,
|
|
|
|
|
value: item[leftColumn],
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-20 12:19:27 +09:00
|
|
|
// 추가로 다른 의미있는 필드 1-2개 표시 (동적)
|
2025-11-07 11:51:44 +09:00
|
|
|
const additionalKeys = Object.keys(item).filter(
|
2025-11-13 17:55:10 +09:00
|
|
|
(k) =>
|
|
|
|
|
k !== "id" &&
|
|
|
|
|
k !== "ID" &&
|
|
|
|
|
k !== leftColumn &&
|
2025-11-20 12:19:27 +09:00
|
|
|
shouldShowField(k),
|
2025-11-07 11:51:44 +09:00
|
|
|
);
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 11:51:44 +09:00
|
|
|
if (additionalKeys.length > 0) {
|
|
|
|
|
displayFields.push({
|
|
|
|
|
label: additionalKeys[0],
|
|
|
|
|
value: item[additionalKeys[0]],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (index === 0) {
|
|
|
|
|
console.log(" ✅ 조인 키 기반 표시:", displayFields);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// 상세 모드 또는 설정 없음: 자동으로 첫 2개 필드 표시
|
|
|
|
|
const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID");
|
|
|
|
|
displayFields = keys.slice(0, 2).map((key) => ({
|
|
|
|
|
label: key,
|
|
|
|
|
value: item[key],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (index === 0) {
|
|
|
|
|
console.log(" ⚠️ 조인 키 없음, 자동 선택:", displayFields);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`;
|
|
|
|
|
const displaySubtitle = displayFields[1]?.value || null;
|
2025-10-16 15:05:24 +09:00
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
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
|
|
|
>
|
2025-11-13 17:55:10 +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>
|
2025-11-13 17:55:10 +09:00
|
|
|
|
|
|
|
|
{/* 항목별 버튼들 */}
|
|
|
|
|
{!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>
|
|
|
|
|
|
2025-11-13 17:55:10 +09:00
|
|
|
{/* 자식 항목들 (접혀있으면 표시 안함) */}
|
|
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
})()
|
|
|
|
|
)}
|
2025-11-11 11:37:26 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-15 17:25:38 +09:00
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 리사이저 */}
|
|
|
|
|
{resizable && (
|
|
|
|
|
<div
|
|
|
|
|
onMouseDown={handleMouseDown}
|
2025-11-13 17:55:10 +09:00
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 우측 패널 */}
|
2025-10-17 15:31:23 +09:00
|
|
|
<div
|
2025-11-05 16:18:00 +09:00
|
|
|
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px`, height: "100%" }}
|
2025-10-17 15:31:23 +09:00
|
|
|
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 && (
|
2025-11-13 17:55:10 +09:00
|
|
|
<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">
|
2025-11-13 17:55:10 +09:00
|
|
|
<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-16 15:05:24 +09:00
|
|
|
{/* 우측 데이터 */}
|
2025-10-15 17:25:38 +09:00
|
|
|
{isLoadingRight ? (
|
|
|
|
|
// 로딩 중
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
<div className="text-center">
|
2025-11-13 17:55:10 +09:00
|
|
|
<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 ? (
|
|
|
|
|
// 실제 데이터 표시
|
2025-10-16 15:05:24 +09:00
|
|
|
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;
|
|
|
|
|
|
2025-11-11 11:37:26 +09:00
|
|
|
// 테이블 모드 체크
|
|
|
|
|
const isTableMode = componentConfig.rightPanel?.displayMode === "table";
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-11 11:37:26 +09:00
|
|
|
if (isTableMode) {
|
|
|
|
|
// 테이블 모드 렌더링
|
|
|
|
|
const displayColumns = componentConfig.rightPanel?.columns || [];
|
2025-11-13 17:55:10 +09:00
|
|
|
const columnsToShow =
|
|
|
|
|
displayColumns.length > 0
|
|
|
|
|
? displayColumns.map((col) => ({
|
|
|
|
|
...col,
|
|
|
|
|
label: rightColumnLabels[col.name] || col.label || col.name,
|
|
|
|
|
}))
|
|
|
|
|
: Object.keys(filteredData[0] || {})
|
2025-11-20 12:19:27 +09:00
|
|
|
.filter((key) => shouldShowField(key))
|
2025-11-13 17:55:10 +09:00
|
|
|
.slice(0, 5)
|
|
|
|
|
.map((key) => ({
|
|
|
|
|
name: key,
|
|
|
|
|
label: rightColumnLabels[key] || key,
|
|
|
|
|
width: 150,
|
|
|
|
|
align: "left" as const,
|
|
|
|
|
}));
|
2025-11-11 11:37:26 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="w-full">
|
2025-11-13 17:55:10 +09:00
|
|
|
<div className="text-muted-foreground mb-2 text-xs">
|
2025-11-11 11:37:26 +09:00
|
|
|
{filteredData.length}개의 관련 데이터
|
|
|
|
|
{rightSearchQuery && filteredData.length !== rightData.length && (
|
2025-11-13 17:55:10 +09:00
|
|
|
<span className="text-primary ml-1">(전체 {rightData.length}개 중)</span>
|
2025-11-11 11:37:26 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="overflow-auto">
|
|
|
|
|
<table className="min-w-full divide-y divide-gray-200">
|
2025-11-13 17:55:10 +09:00
|
|
|
<thead className="sticky top-0 z-10 bg-gray-50">
|
2025-11-11 11:37:26 +09:00
|
|
|
<tr>
|
|
|
|
|
{columnsToShow.map((col, idx) => (
|
|
|
|
|
<th
|
|
|
|
|
key={idx}
|
2025-11-13 17:55:10 +09:00
|
|
|
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",
|
|
|
|
|
}}
|
2025-11-11 11:37:26 +09:00
|
|
|
>
|
|
|
|
|
{col.label}
|
|
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
{!isDesignMode && (
|
2025-11-13 17:55:10 +09:00
|
|
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
|
|
|
|
작업
|
|
|
|
|
</th>
|
2025-11-11 11:37:26 +09:00
|
|
|
)}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-gray-200 bg-white">
|
|
|
|
|
{filteredData.map((item, idx) => {
|
|
|
|
|
const itemId = item.id || item.ID || idx;
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-11 11:37:26 +09:00
|
|
|
return (
|
2025-11-13 17:55:10 +09:00
|
|
|
<tr key={itemId} className="hover:bg-accent transition-colors">
|
2025-11-11 11:37:26 +09:00
|
|
|
{columnsToShow.map((col, colIdx) => (
|
|
|
|
|
<td
|
|
|
|
|
key={colIdx}
|
2025-11-13 17:55:10 +09:00
|
|
|
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
2025-11-11 11:37:26 +09:00
|
|
|
style={{ textAlign: col.align || "left" }}
|
|
|
|
|
>
|
2025-11-13 17:52:33 +09:00
|
|
|
{formatCellValue(col.name, item[col.name], rightCategoryMappings)}
|
2025-11-11 11:37:26 +09:00
|
|
|
</td>
|
|
|
|
|
))}
|
|
|
|
|
{!isDesignMode && (
|
2025-11-13 17:55:10 +09:00
|
|
|
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
2025-11-11 11:37:26 +09:00
|
|
|
<div className="flex justify-end gap-1">
|
2025-11-20 10:23:54 +09:00
|
|
|
{(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="h-3 w-3 mr-1" />
|
|
|
|
|
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2025-11-11 11:37:26 +09:00
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleDeleteClick("right", item);
|
|
|
|
|
}}
|
2025-11-13 17:55:10 +09:00
|
|
|
className="rounded p-1 transition-colors hover:bg-red-100"
|
2025-11-11 11:37:26 +09:00
|
|
|
title="삭제"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4 text-red-600" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 목록 모드 (기존)
|
2025-10-16 15:05:24 +09:00
|
|
|
return filteredData.length > 0 ? (
|
|
|
|
|
<div className="space-y-2">
|
2025-11-13 17:55:10 +09:00
|
|
|
<div className="text-muted-foreground mb-2 text-xs">
|
2025-10-16 15:05:24 +09:00
|
|
|
{filteredData.length}개의 관련 데이터
|
|
|
|
|
{rightSearchQuery && filteredData.length !== rightData.length && (
|
2025-11-13 17:55:10 +09:00
|
|
|
<span className="text-primary ml-1">(전체 {rightData.length}개 중)</span>
|
2025-10-16 15:05:24 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{filteredData.map((item, index) => {
|
|
|
|
|
const itemId = item.id || item.ID || index;
|
|
|
|
|
const isExpanded = expandedRightItems.has(itemId);
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 12:00:46 +09:00
|
|
|
// 우측 패널 표시 컬럼 설정 확인
|
|
|
|
|
const rightColumns = componentConfig.rightPanel?.columns;
|
2025-11-20 10:23:54 +09:00
|
|
|
let firstValues: [string, any, string][] = [];
|
|
|
|
|
let allValues: [string, any, string][] = [];
|
2025-11-07 12:00:46 +09:00
|
|
|
|
|
|
|
|
if (rightColumns && rightColumns.length > 0) {
|
2025-11-20 10:23:54 +09:00
|
|
|
// 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리)
|
|
|
|
|
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
2025-11-07 12:00:46 +09:00
|
|
|
firstValues = rightColumns
|
2025-11-20 10:23:54 +09:00
|
|
|
.slice(0, summaryCount)
|
|
|
|
|
.map((col) => {
|
|
|
|
|
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name)
|
|
|
|
|
let value = item[col.name];
|
|
|
|
|
if (value === undefined && col.name.includes('.')) {
|
|
|
|
|
const columnName = col.name.split('.').pop();
|
|
|
|
|
// 1차: 컬럼명 그대로 (예: item_number)
|
|
|
|
|
value = item[columnName || ''];
|
|
|
|
|
// 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인
|
|
|
|
|
if (value === undefined) {
|
|
|
|
|
const parts = col.name.split('.');
|
|
|
|
|
if (parts.length === 2) {
|
|
|
|
|
const refTable = parts[0]; // item_info
|
|
|
|
|
const refColumn = parts[1]; // item_number 또는 item_name
|
|
|
|
|
// FK 컬럼명 추론: item_info → item_id
|
|
|
|
|
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id';
|
|
|
|
|
|
|
|
|
|
// 백엔드에서 반환하는 별칭 패턴:
|
|
|
|
|
// 1) item_id_name (기본 referenceColumn)
|
|
|
|
|
// 2) item_id_item_name (추가 컬럼)
|
|
|
|
|
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' ||
|
|
|
|
|
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') {
|
|
|
|
|
// 기본 참조 컬럼 (item_number, customer_code 등)
|
|
|
|
|
const aliasKey = fkColumn + '_name';
|
|
|
|
|
value = item[aliasKey];
|
|
|
|
|
} else {
|
|
|
|
|
// 추가 컬럼 (item_name, customer_name 등)
|
|
|
|
|
const aliasKey = `${fkColumn}_${refColumn}`;
|
|
|
|
|
value = item[aliasKey];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [col.name, value, col.label] as [string, any, string];
|
|
|
|
|
})
|
2025-11-07 12:00:46 +09:00
|
|
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-07 12:00:46 +09:00
|
|
|
allValues = rightColumns
|
2025-11-20 10:23:54 +09:00
|
|
|
.map((col) => {
|
|
|
|
|
// 🆕 엔티티 조인 컬럼 처리
|
|
|
|
|
let value = item[col.name];
|
|
|
|
|
if (value === undefined && col.name.includes('.')) {
|
|
|
|
|
const columnName = col.name.split('.').pop();
|
|
|
|
|
// 1차: 컬럼명 그대로
|
|
|
|
|
value = item[columnName || ''];
|
|
|
|
|
// 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인
|
|
|
|
|
if (value === undefined) {
|
|
|
|
|
const parts = col.name.split('.');
|
|
|
|
|
if (parts.length === 2) {
|
|
|
|
|
const refTable = parts[0]; // item_info
|
|
|
|
|
const refColumn = parts[1]; // item_number 또는 item_name
|
|
|
|
|
// FK 컬럼명 추론: item_info → item_id
|
|
|
|
|
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id';
|
|
|
|
|
|
|
|
|
|
// 백엔드에서 반환하는 별칭 패턴:
|
|
|
|
|
// 1) item_id_name (기본 referenceColumn)
|
|
|
|
|
// 2) item_id_item_name (추가 컬럼)
|
|
|
|
|
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' ||
|
|
|
|
|
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') {
|
|
|
|
|
// 기본 참조 컬럼
|
|
|
|
|
const aliasKey = fkColumn + '_name';
|
|
|
|
|
value = item[aliasKey];
|
|
|
|
|
} else {
|
|
|
|
|
// 추가 컬럼
|
|
|
|
|
const aliasKey = `${fkColumn}_${refColumn}`;
|
|
|
|
|
value = item[aliasKey];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [col.name, value, col.label] as [string, any, string];
|
|
|
|
|
})
|
2025-11-07 12:00:46 +09:00
|
|
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
|
|
|
|
} else {
|
|
|
|
|
// 설정 없으면 모든 컬럼 표시 (기존 로직)
|
2025-11-20 10:23:54 +09:00
|
|
|
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
2025-11-07 12:00:46 +09:00
|
|
|
firstValues = Object.entries(item)
|
|
|
|
|
.filter(([key]) => !key.toLowerCase().includes("id"))
|
2025-11-20 10:23:54 +09:00
|
|
|
.slice(0, summaryCount)
|
|
|
|
|
.map(([key, value]) => [key, value, ''] as [string, any, string]);
|
2025-11-07 12:00:46 +09:00
|
|
|
|
2025-11-20 10:23:54 +09:00
|
|
|
allValues = Object.entries(item)
|
|
|
|
|
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
|
|
|
|
|
.map(([key, value]) => [key, value, ''] as [string, any, string]);
|
2025-11-07 12:00:46 +09:00
|
|
|
}
|
2025-10-16 15:05:24 +09:00
|
|
|
|
|
|
|
|
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-10-16 15:05:24 +09:00
|
|
|
>
|
2025-11-07 16:02:01 +09:00
|
|
|
{/* 요약 정보 */}
|
|
|
|
|
<div className="p-3">
|
2025-10-16 15:05:24 +09:00
|
|
|
<div className="flex items-start justify-between gap-2">
|
2025-11-13 17:55:10 +09:00
|
|
|
<div
|
2025-11-07 16:02:01 +09:00
|
|
|
className="min-w-0 flex-1 cursor-pointer"
|
|
|
|
|
onClick={() => toggleRightItemExpansion(itemId)}
|
|
|
|
|
>
|
2025-11-20 10:23:54 +09:00
|
|
|
<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;
|
|
|
|
|
|
|
|
|
|
// 숫자 포맷 적용
|
|
|
|
|
let displayValue = String(value || "-");
|
|
|
|
|
if (value !== null && value !== undefined && value !== "" && format) {
|
|
|
|
|
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
|
|
|
|
if (!isNaN(numValue)) {
|
|
|
|
|
displayValue = numValue.toLocaleString('ko-KR', {
|
|
|
|
|
minimumFractionDigits: format.decimalPlaces ?? 0,
|
|
|
|
|
maximumFractionDigits: format.decimalPlaces ?? 10,
|
|
|
|
|
useGrouping: format.thousandSeparator ?? false,
|
|
|
|
|
});
|
|
|
|
|
if (format.prefix) displayValue = format.prefix + displayValue;
|
|
|
|
|
if (format.suffix) displayValue = displayValue + format.suffix;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
|
|
|
|
|
|
|
|
|
|
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' : ''}`}
|
|
|
|
|
title={displayValue}
|
|
|
|
|
>
|
|
|
|
|
{displayValue}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2025-10-16 15:05:24 +09:00
|
|
|
</div>
|
2025-11-07 16:02:01 +09:00
|
|
|
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
|
|
|
|
|
{/* 수정 버튼 */}
|
2025-11-20 10:23:54 +09:00
|
|
|
{!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);
|
|
|
|
|
}}
|
2025-11-20 10:23:54 +09:00
|
|
|
className="h-7"
|
2025-11-07 16:02:01 +09:00
|
|
|
>
|
2025-11-20 10:23:54 +09:00
|
|
|
<Pencil className="h-3 w-3 mr-1" />
|
|
|
|
|
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
|
|
|
|
</Button>
|
2025-10-16 15:05:24 +09:00
|
|
|
)}
|
2025-11-07 16:02:01 +09:00
|
|
|
{/* 삭제 버튼 */}
|
|
|
|
|
{!isDesignMode && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleDeleteClick("right", item);
|
|
|
|
|
}}
|
2025-11-13 17:55:10 +09:00
|
|
|
className="rounded p-1 transition-colors hover:bg-red-100"
|
2025-11-07 16:02:01 +09:00
|
|
|
title="삭제"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4 text-red-600" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{/* 확장/접기 버튼 */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => toggleRightItemExpansion(itemId)}
|
2025-11-13 17:55:10 +09:00
|
|
|
className="rounded p-1 transition-colors hover:bg-gray-200"
|
2025-11-07 16:02:01 +09:00
|
|
|
>
|
|
|
|
|
{isExpanded ? (
|
2025-11-13 17:55:10 +09:00
|
|
|
<ChevronUp className="text-muted-foreground h-5 w-5" />
|
2025-11-07 16:02:01 +09:00
|
|
|
) : (
|
2025-11-13 17:55:10 +09:00
|
|
|
<ChevronDown className="text-muted-foreground h-5 w-5" />
|
2025-11-07 16:02:01 +09:00
|
|
|
)}
|
|
|
|
|
</button>
|
2025-10-16 15:05:24 +09:00
|
|
|
</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">
|
2025-10-16 15:05:24 +09:00
|
|
|
<table className="w-full text-sm">
|
2025-11-13 17:55:10 +09:00
|
|
|
<tbody className="divide-border divide-y">
|
2025-11-20 10:23:54 +09:00
|
|
|
{allValues.map(([key, value, label]) => {
|
|
|
|
|
// 포맷 설정 찾기
|
|
|
|
|
const colConfig = rightColumns?.find(c => c.name === key);
|
|
|
|
|
const format = colConfig?.format;
|
|
|
|
|
|
|
|
|
|
// 숫자 포맷 적용
|
|
|
|
|
let displayValue = String(value);
|
|
|
|
|
if (value !== null && value !== undefined && value !== "" && format) {
|
|
|
|
|
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
|
|
|
|
if (!isNaN(numValue)) {
|
|
|
|
|
displayValue = numValue.toLocaleString('ko-KR', {
|
|
|
|
|
minimumFractionDigits: format.decimalPlaces ?? 0,
|
|
|
|
|
maximumFractionDigits: format.decimalPlaces ?? 10,
|
|
|
|
|
useGrouping: format.thousandSeparator ?? false,
|
|
|
|
|
});
|
|
|
|
|
if (format.prefix) displayValue = format.prefix + displayValue;
|
|
|
|
|
if (format.suffix) displayValue = displayValue + format.suffix;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2025-10-16 15:05:24 +09:00
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-11-20 10:23:54 +09:00
|
|
|
);
|
|
|
|
|
})}
|
2025-10-16 15:05:24 +09:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2025-11-13 17:55:10 +09:00
|
|
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
2025-10-16 15:05:24 +09:00
|
|
|
{rightSearchQuery ? (
|
|
|
|
|
<>
|
|
|
|
|
<p>검색 결과가 없습니다.</p>
|
2025-11-13 17:55:10 +09:00
|
|
|
<p className="text-muted-foreground/70 mt-1 text-xs">다른 검색어를 입력해보세요.</p>
|
2025-10-16 15:05:24 +09:00
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
"관련 데이터가 없습니다."
|
|
|
|
|
)}
|
2025-10-15 17:25:38 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
2025-10-16 15:05:24 +09:00
|
|
|
})()
|
|
|
|
|
) : (
|
|
|
|
|
// 상세 모드: 단일 객체를 상세 정보로 표시
|
2025-11-07 12:00:46 +09:00
|
|
|
(() => {
|
|
|
|
|
const rightColumns = componentConfig.rightPanel?.columns;
|
2025-11-20 10:23:54 +09:00
|
|
|
let displayEntries: [string, any, string][] = [];
|
2025-11-07 12:00:46 +09:00
|
|
|
|
|
|
|
|
if (rightColumns && rightColumns.length > 0) {
|
2025-11-20 10:23:54 +09:00
|
|
|
console.log("🔍 [디버깅] 상세 모드 표시 로직:");
|
|
|
|
|
console.log(" 📋 rightData 전체:", rightData);
|
|
|
|
|
console.log(" 📋 rightData keys:", Object.keys(rightData));
|
|
|
|
|
console.log(" ⚙️ 설정된 컬럼:", rightColumns.map((c) => `${c.name} (${c.label})`));
|
|
|
|
|
|
2025-11-07 12:00:46 +09:00
|
|
|
// 설정된 컬럼만 표시
|
|
|
|
|
displayEntries = rightColumns
|
2025-11-20 10:23:54 +09:00
|
|
|
.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;
|
|
|
|
|
});
|
2025-11-13 17:55:10 +09:00
|
|
|
|
2025-11-20 10:23:54 +09:00
|
|
|
console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개");
|
2025-11-07 12:00:46 +09:00
|
|
|
} else {
|
|
|
|
|
// 설정 없으면 모든 컬럼 표시
|
2025-11-20 10:23:54 +09:00
|
|
|
displayEntries = Object.entries(rightData)
|
|
|
|
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "")
|
|
|
|
|
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
2025-11-07 12:00:46 +09:00
|
|
|
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
2025-11-20 10:23:54 +09:00
|
|
|
{displayEntries.map(([key, value, label]) => (
|
2025-11-07 12:00:46 +09:00
|
|
|
<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">
|
2025-11-20 10:23:54 +09:00
|
|
|
{label || getColumnLabel(key)}
|
2025-11-07 12:00:46 +09:00
|
|
|
</div>
|
|
|
|
|
<div className="text-sm">{String(value)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()
|
2025-10-16 15:05:24 +09:00
|
|
|
)
|
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">
|
2025-10-30 15:39:39 +09:00
|
|
|
<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">
|
2025-10-30 15:39:39 +09:00
|
|
|
<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">
|
2025-10-30 15:39:39 +09:00
|
|
|
<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">
|
2025-11-13 17:55:10 +09:00
|
|
|
<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>
|
2025-11-07 14:22:23 +09:00
|
|
|
|
|
|
|
|
{/* 추가 모달 */}
|
|
|
|
|
<Dialog open={showAddModal} onOpenChange={setShowAddModal}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="text-base sm:text-lg">
|
2025-11-13 17:55:10 +09:00
|
|
|
{addModalPanel === "left"
|
2025-11-07 15:21:44 +09:00
|
|
|
? `${componentConfig.leftPanel?.title} 추가`
|
|
|
|
|
: addModalPanel === "right"
|
2025-11-13 17:55:10 +09:00
|
|
|
? `${componentConfig.rightPanel?.title} 추가`
|
|
|
|
|
: `하위 ${componentConfig.leftPanel?.title} 추가`}
|
2025-11-07 14:22:23 +09:00
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-xs sm:text-sm">
|
2025-11-13 17:55:10 +09:00
|
|
|
{addModalPanel === "left-item"
|
2025-11-07 15:21:44 +09:00
|
|
|
? "선택한 항목의 하위 항목을 추가합니다. 필수 항목을 입력해주세요."
|
|
|
|
|
: "새로운 데이터를 추가합니다. 필수 항목을 입력해주세요."}
|
2025-11-07 14:22:23 +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-13 17:55:10 +09:00
|
|
|
|
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-13 17:55:10 +09:00
|
|
|
|
2025-11-07 15:21:44 +09:00
|
|
|
return modalColumns?.map((col, index) => {
|
2025-11-13 17:55:10 +09:00
|
|
|
// 항목별 추가 버튼으로 열렸을 때, 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
|
|
|
});
|
|
|
|
|
})()}
|
2025-11-07 14:22:23 +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>
|
2025-11-13 17:55:10 +09:00
|
|
|
<Button onClick={handleAddModalSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
2025-11-07 14:22:23 +09:00
|
|
|
<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">
|
2025-11-13 17:55:10 +09:00
|
|
|
{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">
|
2025-11-13 17:55:10 +09:00
|
|
|
{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
|
|
|
|
2025-11-13 17:55:10 +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
|
2025-11-13 17:55:10 +09:00
|
|
|
id={`edit-${leftColumn}`}
|
|
|
|
|
value={editModalFormData[leftColumn] || ""}
|
2025-11-07 16:02:01 +09:00
|
|
|
onChange={(e) => {
|
2025-11-13 17:55:10 +09:00
|
|
|
setEditModalFormData((prev) => ({
|
2025-11-07 16:02:01 +09:00
|
|
|
...prev,
|
2025-11-13 17:55:10 +09:00
|
|
|
[leftColumn]: e.target.value,
|
2025-11-07 16:02:01 +09:00
|
|
|
}));
|
|
|
|
|
}}
|
2025-11-13 17:55:10 +09:00
|
|
|
placeholder={`${leftColumn} 입력`}
|
2025-11-07 16:02:01 +09:00
|
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-11-13 17:55:10 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 우측 패널 수정: 우측 패널에 설정된 표시 컬럼들만
|
|
|
|
|
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
|
2025-11-13 17:55:10 +09:00
|
|
|
id={`edit-${col.name}`}
|
|
|
|
|
value={editModalFormData[col.name] || ""}
|
2025-11-07 16:02:01 +09:00
|
|
|
onChange={(e) => {
|
2025-11-13 17:55:10 +09:00
|
|
|
setEditModalFormData((prev) => ({
|
2025-11-07 16:02:01 +09:00
|
|
|
...prev,
|
2025-11-13 17:55:10 +09:00
|
|
|
[col.name]: e.target.value,
|
2025-11-07 16:02:01 +09:00
|
|
|
}));
|
|
|
|
|
}}
|
2025-11-13 17:55:10 +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>
|
|
|
|
|
));
|
2025-11-13 17:55:10 +09:00
|
|
|
} else {
|
2025-11-20 12:19:27 +09:00
|
|
|
// 설정이 없으면 모든 컬럼 표시 (민감한 필드 제외)
|
2025-11-13 17:55:10 +09:00
|
|
|
return Object.entries(editModalFormData)
|
2025-11-20 12:19:27 +09:00
|
|
|
.filter(([key]) => shouldShowField(key))
|
2025-11-13 17:55:10 +09:00
|
|
|
.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
|
|
|
}
|
2025-11-13 17:55:10 +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>
|
2025-11-13 17:55:10 +09:00
|
|
|
<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} />;
|
|
|
|
|
};
|