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

1881 lines
81 KiB
TypeScript

"use client";
import React, { useState, useCallback, useEffect } from "react";
import { ComponentRendererProps } from "../../types";
import { SplitPanelLayoutConfig } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight, Pencil, Trash2 } from "lucide-react";
import { dataApi } from "@/lib/api/data";
import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
}
/**
* SplitPanelLayout 컴포넌트
* 마스터-디테일 패턴의 좌우 분할 레이아웃
*/
export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
isPreview = false,
onClick,
...props
}) => {
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
// 기본 설정값
const splitRatio = componentConfig.splitRatio || 30;
const resizable = componentConfig.resizable ?? true;
const minLeftWidth = componentConfig.minLeftWidth || 200;
const minRightWidth = componentConfig.minRightWidth || 300;
// TableOptions Context
const { registerTable, unregisterTable } = useTableOptions();
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
const [leftColumnVisibility, setLeftColumnVisibility] = useState<ColumnVisibility[]>([]);
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
// 데이터 상태
const [leftData, setLeftData] = useState<any[]>([]);
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
const [leftSearchQuery, setLeftSearchQuery] = useState("");
const [rightSearchQuery, setRightSearchQuery] = useState("");
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
const { toast } = useToast();
// 추가 모달 상태
const [showAddModal, setShowAddModal] = useState(false);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
// 수정 모달 상태
const [showEditModal, setShowEditModal] = useState(false);
const [editModalPanel, setEditModalPanel] = useState<"left" | "right" | null>(null);
const [editModalItem, setEditModalItem] = useState<any>(null);
const [editModalFormData, setEditModalFormData] = useState<Record<string, any>>({});
// 삭제 확인 모달 상태
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null);
const [deleteModalItem, setDeleteModalItem] = useState<any>(null);
// 리사이저 드래그 상태
const [isDragging, setIsDragging] = useState(false);
const [leftWidth, setLeftWidth] = useState(splitRatio);
const containerRef = React.useRef<HTMLDivElement>(null);
// 컴포넌트 스타일
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
const getHeightValue = () => {
const height = component.style?.height;
if (!height) return "600px";
if (typeof height === "string") return height; // 이미 '540px' 형태
return `${height}px`; // 숫자면 px 추가
};
const componentStyle: React.CSSProperties = isPreview
? {
// 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용
position: "relative",
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
height: getHeightValue(),
border: "1px solid #e5e7eb",
}
: {
// 디자이너 모드: position absolute
position: "absolute",
left: `${component.style?.positionX || 0}px`,
top: `${component.style?.positionY || 0}px`,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤 (그리드 기반)
height: getHeightValue(),
zIndex: component.style?.positionZ || 1,
cursor: isDesignMode ? "pointer" : "default",
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
};
// 계층 구조 빌드 함수 (트리 구조 유지)
const buildHierarchy = useCallback((items: any[]): any[] => {
if (!items || items.length === 0) return [];
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
if (!itemAddConfig) return items.map(item => ({ ...item, children: [] })); // 계층 설정이 없으면 평면 목록
const { sourceColumn, parentColumn } = itemAddConfig;
if (!sourceColumn || !parentColumn) return items.map(item => ({ ...item, children: [] }));
// ID를 키로 하는 맵 생성
const itemMap = new Map<any, any>();
const rootItems: any[] = [];
// 모든 항목을 맵에 추가하고 children 배열 초기화
items.forEach(item => {
const id = item[sourceColumn];
itemMap.set(id, { ...item, children: [], level: 0 });
});
// 부모-자식 관계 설정
items.forEach(item => {
const id = item[sourceColumn];
const parentId = item[parentColumn];
const currentItem = itemMap.get(id);
if (!currentItem) return;
if (!parentId || parentId === null || parentId === '') {
// 최상위 항목
rootItems.push(currentItem);
} else {
// 부모가 있는 항목
const parentItem = itemMap.get(parentId);
if (parentItem) {
currentItem.level = parentItem.level + 1;
parentItem.children.push(currentItem);
} else {
// 부모를 찾을 수 없으면 최상위로 처리
rootItems.push(currentItem);
}
}
});
return rootItems;
}, [componentConfig.leftPanel?.itemAddConfig]);
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
setIsLoadingLeft(true);
try {
const result = await dataApi.getTableData(leftTableName, {
page: 1,
size: 100,
// searchTerm 제거 - 클라이언트 사이드에서 필터링
});
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && result.data.length > 0) {
result.data.sort((a, b) => {
const aValue = String(a[leftColumn] || '');
const bValue = String(b[leftColumn] || '');
return aValue.localeCompare(bValue, 'ko-KR');
});
}
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(result.data);
setLeftData(hierarchicalData);
} catch (error) {
console.error("좌측 데이터 로드 실패:", error);
toast({
title: "데이터 로드 실패",
description: "좌측 패널 데이터를 불러올 수 없습니다.",
variant: "destructive",
});
} finally {
setIsLoadingLeft(false);
}
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy]);
// 우측 데이터 로드
const loadRightData = useCallback(
async (leftItem: any) => {
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
setIsLoadingRight(true);
try {
if (relationshipType === "detail") {
// 상세 모드: 동일 테이블의 상세 정보
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
setRightData(detail);
} else if (relationshipType === "join") {
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
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,
);
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
}
}
} catch (error) {
console.error("우측 데이터 로드 실패:", error);
toast({
title: "데이터 로드 실패",
description: "우측 패널 데이터를 불러올 수 없습니다.",
variant: "destructive",
});
} finally {
setIsLoadingRight(false);
}
},
[
componentConfig.rightPanel?.tableName,
componentConfig.rightPanel?.relation,
componentConfig.leftPanel?.tableName,
isDesignMode,
toast,
],
);
// 좌측 항목 선택 핸들러
const handleLeftItemSelect = useCallback(
(item: any) => {
setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
loadRightData(item);
},
[loadRightData],
);
// 우측 항목 확장/축소 토글
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],
);
// 좌측 테이블 등록 (Context에 등록)
useEffect(() => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
const leftTableId = `split-panel-left-${component.id}`;
const leftColumns = componentConfig.leftPanel?.displayColumns || [];
if (leftColumns.length > 0) {
registerTable({
tableId: leftTableId,
label: `${component.title || "분할 패널"} (좌측)`,
tableName: leftTableName,
columns: leftColumns.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,
});
return () => unregisterTable(leftTableId);
}
}, [component.id, componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.displayColumns, leftColumnLabels, component.title, isDesignMode]);
// 우측 테이블 등록 (Context에 등록)
useEffect(() => {
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
const rightTableId = `split-panel-right-${component.id}`;
const rightColumns = rightTableColumns.map((col: any) => col.columnName || col.column_name).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, rightTableColumns, rightColumnLabels, component.title, isDesignMode]);
// 좌측 테이블 컬럼 라벨 로드
useEffect(() => {
const loadLeftColumnLabels = async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
try {
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
const labels: Record<string, string> = {};
columnsResponse.forEach((col: any) => {
const columnName = col.columnName || col.column_name;
const label = col.columnLabel || col.column_label || col.displayName || columnName;
if (columnName) {
labels[columnName] = label;
}
});
setLeftColumnLabels(labels);
console.log("✅ 좌측 컬럼 라벨 로드:", labels);
} catch (error) {
console.error("좌측 테이블 컬럼 라벨 로드 실패:", error);
}
};
loadLeftColumnLabels();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
// 우측 테이블 컬럼 정보 로드
useEffect(() => {
const loadRightTableColumns = async () => {
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
try {
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
setRightTableColumns(columnsResponse || []);
// 우측 컬럼 라벨도 함께 로드
const labels: Record<string, string> = {};
columnsResponse.forEach((col: any) => {
const columnName = col.columnName || col.column_name;
const label = col.columnLabel || col.column_label || col.displayName || columnName;
if (columnName) {
labels[columnName] = label;
}
});
setRightColumnLabels(labels);
console.log("✅ 우측 컬럼 라벨 로드:", labels);
} catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
}
};
loadRightTableColumns();
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
// 항목 펼치기/접기 토글
const toggleExpand = useCallback((itemId: any) => {
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
}, []);
// 추가 버튼 핸들러
const handleAddClick = useCallback((panel: "left" | "right") => {
setAddModalPanel(panel);
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
if (panel === "right" && selectedLeftItem && componentConfig.leftPanel?.leftColumn && componentConfig.rightPanel?.rightColumn) {
const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn];
setAddModalFormData({
[componentConfig.rightPanel.rightColumn]: leftColumnValue
});
} else {
setAddModalFormData({});
}
setShowAddModal(true);
}, [selectedLeftItem, componentConfig]);
// 수정 버튼 핸들러
const handleEditClick = useCallback((panel: "left" | "right", item: any) => {
setEditModalPanel(panel);
setEditModalItem(item);
setEditModalFormData({ ...item });
setShowEditModal(true);
}, []);
// 수정 모달 저장
const handleEditModalSave = useCallback(async () => {
const tableName = editModalPanel === "left"
? componentConfig.leftPanel?.tableName
: componentConfig.rightPanel?.tableName;
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
const primaryKey = editModalItem[sourceColumn] || editModalItem.id || editModalItem.ID;
if (!tableName || !primaryKey) {
toast({
title: "수정 오류",
description: "테이블명 또는 Primary Key가 없습니다.",
variant: "destructive",
});
return;
}
try {
console.log("📝 데이터 수정:", { tableName, primaryKey, data: editModalFormData });
// 프론트엔드 전용 필드 제거 (children, level 등)
const cleanData = { ...editModalFormData };
delete cleanData.children;
delete cleanData.level;
// 좌측 패널 수정 시, 조인 관계 정보 포함
let updatePayload: any = cleanData;
if (editModalPanel === "left" && componentConfig.rightPanel?.relation?.type === "join") {
// 조인 관계가 있는 경우, 관계 정보를 페이로드에 추가
updatePayload._relationInfo = {
rightTable: componentConfig.rightPanel.tableName,
leftColumn: componentConfig.rightPanel.relation.leftColumn,
rightColumn: componentConfig.rightPanel.relation.rightColumn,
oldLeftValue: editModalItem[componentConfig.rightPanel.relation.leftColumn],
};
console.log("🔗 조인 관계 정보 추가:", updatePayload._relationInfo);
}
const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload);
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 수정되었습니다.",
});
// 모달 닫기
setShowEditModal(false);
setEditModalFormData({});
setEditModalItem(null);
// 데이터 새로고침
if (editModalPanel === "left") {
loadLeftData();
// 우측 패널도 새로고침 (FK가 변경되었을 수 있음)
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} else if (editModalPanel === "right" && selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} else {
toast({
title: "수정 실패",
description: result.message || "데이터 수정에 실패했습니다.",
variant: "destructive",
});
}
} catch (error: any) {
console.error("데이터 수정 오류:", error);
toast({
title: "오류",
description: error?.response?.data?.message || "데이터 수정 중 오류가 발생했습니다.",
variant: "destructive",
});
}
}, [editModalPanel, componentConfig, editModalItem, editModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]);
// 삭제 버튼 핸들러
const handleDeleteClick = useCallback((panel: "left" | "right", item: any) => {
setDeleteModalPanel(panel);
setDeleteModalItem(item);
setShowDeleteModal(true);
}, []);
// 삭제 확인
const handleDeleteConfirm = useCallback(async () => {
// 우측 패널 삭제 시 중계 테이블 확인
let tableName = deleteModalPanel === "left"
? componentConfig.leftPanel?.tableName
: componentConfig.rightPanel?.tableName;
// 우측 패널 + 중계 테이블 모드인 경우
if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) {
tableName = componentConfig.rightPanel.addConfig.targetTable;
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
}
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID;
// 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리)
if (deleteModalItem && typeof deleteModalItem === 'object') {
primaryKey = deleteModalItem;
console.log("🔑 복합키 가능성: 전체 객체 전달", primaryKey);
}
if (!tableName || !primaryKey) {
toast({
title: "삭제 오류",
description: "테이블명 또는 Primary Key가 없습니다.",
variant: "destructive",
});
return;
}
try {
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
const result = await dataApi.deleteRecord(tableName, primaryKey);
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 삭제되었습니다.",
});
// 모달 닫기
setShowDeleteModal(false);
setDeleteModalItem(null);
// 데이터 새로고침
if (deleteModalPanel === "left") {
loadLeftData();
// 삭제된 항목이 선택되어 있었으면 선택 해제
if (selectedLeftItem && selectedLeftItem[sourceColumn] === primaryKey) {
setSelectedLeftItem(null);
setRightData(null);
}
} else if (deleteModalPanel === "right" && selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} else {
toast({
title: "삭제 실패",
description: result.message || "데이터 삭제에 실패했습니다.",
variant: "destructive",
});
}
} catch (error: any) {
console.error("데이터 삭제 오류:", error);
// 외래키 제약조건 에러 처리
let errorMessage = "데이터 삭제 중 오류가 발생했습니다.";
if (error?.response?.data?.error?.includes("foreign key")) {
errorMessage = "이 데이터를 참조하는 다른 데이터가 있어 삭제할 수 없습니다.";
}
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
}
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]);
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
const handleItemAddClick = useCallback((item: any) => {
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
if (!itemAddConfig) {
toast({
title: "설정 오류",
description: "하위 항목 추가 설정이 없습니다.",
variant: "destructive",
});
return;
}
const { sourceColumn, parentColumn } = itemAddConfig;
if (!sourceColumn || !parentColumn) {
toast({
title: "설정 오류",
description: "현재 항목 ID 컬럼과 상위 항목 저장 컬럼을 설정해주세요.",
variant: "destructive",
});
return;
}
// 선택된 항목의 sourceColumn 값을 가져와서 parentColumn에 매핑
const sourceValue = item[sourceColumn];
if (!sourceValue) {
toast({
title: "데이터 오류",
description: `선택한 항목의 ${sourceColumn} 값이 없습니다.`,
variant: "destructive",
});
return;
}
// 좌측 패널 추가 모달 열기 (parentColumn 값 미리 채우기)
setAddModalPanel("left-item");
setAddModalFormData({ [parentColumn]: sourceValue });
setShowAddModal(true);
}, [componentConfig, toast]);
// 추가 모달 저장
const handleAddModalSave = useCallback(async () => {
// 테이블명과 모달 컬럼 결정
let tableName: string | undefined;
let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined;
let finalData = { ...addModalFormData };
if (addModalPanel === "left") {
tableName = componentConfig.leftPanel?.tableName;
modalColumns = componentConfig.leftPanel?.addModalColumns;
} else if (addModalPanel === "right") {
// 우측 패널: 중계 테이블 설정이 있는지 확인
const addConfig = componentConfig.rightPanel?.addConfig;
if (addConfig?.targetTable) {
// 중계 테이블 모드
tableName = addConfig.targetTable;
modalColumns = componentConfig.rightPanel?.addModalColumns;
// 좌측 패널에서 선택된 값 자동 채우기
if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) {
const leftValue = selectedLeftItem[addConfig.leftPanelColumn];
finalData[addConfig.targetColumn] = leftValue;
console.log(`🔗 좌측 패널 값 자동 채움: ${addConfig.targetColumn} = ${leftValue}`);
}
// 자동 채움 컬럼 추가
if (addConfig.autoFillColumns) {
Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => {
finalData[key] = value;
});
console.log("🔧 자동 채움 컬럼:", addConfig.autoFillColumns);
}
} else {
// 일반 테이블 모드
tableName = componentConfig.rightPanel?.tableName;
modalColumns = componentConfig.rightPanel?.addModalColumns;
}
} else if (addModalPanel === "left-item") {
// 하위 항목 추가 (좌측 테이블에 추가)
tableName = componentConfig.leftPanel?.tableName;
modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns;
}
if (!tableName) {
toast({
title: "테이블 오류",
description: "테이블명이 설정되지 않았습니다.",
variant: "destructive",
});
return;
}
// 필수 필드 검증
const requiredFields = (modalColumns || []).filter(col => col.required);
for (const field of requiredFields) {
if (!addModalFormData[field.name]) {
toast({
title: "입력 오류",
description: `${field.label}은(는) 필수 입력 항목입니다.`,
variant: "destructive",
});
return;
}
}
try {
console.log("📝 데이터 추가:", { tableName, data: finalData });
const result = await dataApi.createRecord(tableName, finalData);
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 추가되었습니다.",
});
// 모달 닫기
setShowAddModal(false);
setAddModalFormData({});
// 데이터 새로고침
if (addModalPanel === "left" || addModalPanel === "left-item") {
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
loadLeftData();
} else if (addModalPanel === "right" && selectedLeftItem) {
// 우측 패널 데이터 새로고침
loadRightData(selectedLeftItem);
}
} else {
toast({
title: "저장 실패",
description: result.message || "데이터 추가에 실패했습니다.",
variant: "destructive",
});
}
} catch (error: any) {
console.error("데이터 추가 오류:", error);
// 에러 메시지 추출
let errorMessage = "데이터 추가 중 오류가 발생했습니다.";
if (error?.response?.data) {
const responseData = error.response.data;
// 백엔드에서 반환한 에러 메시지 확인
if (responseData.error) {
// 중복 키 에러 처리
if (responseData.error.includes("duplicate key")) {
errorMessage = "이미 존재하는 값입니다. 다른 값을 입력해주세요.";
}
// NOT NULL 제약조건 에러
else if (responseData.error.includes("null value")) {
const match = responseData.error.match(/column "(\w+)"/);
const columnName = match ? match[1] : "필수";
errorMessage = `${columnName} 필드는 필수 입력 항목입니다.`;
}
// 외래키 제약조건 에러
else if (responseData.error.includes("foreign key")) {
errorMessage = "참조하는 데이터가 존재하지 않습니다.";
}
// 기타 에러
else {
errorMessage = responseData.message || responseData.error;
}
} else if (responseData.message) {
errorMessage = responseData.message;
}
}
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
}
}, [addModalPanel, componentConfig, addModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]);
// 초기 데이터 로드
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, componentConfig.autoLoad]);
// 리사이저 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
if (!resizable) return;
setIsDragging(true);
e.preventDefault();
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const containerWidth = containerRect.width;
const relativeX = e.clientX - containerRect.left;
const newLeftWidth = (relativeX / containerWidth) * 100;
// 최소/최대 너비 제한 (20% ~ 80%)
if (newLeftWidth >= 20 && newLeftWidth <= 80) {
setLeftWidth(newLeftWidth);
}
},
[isDragging],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
React.useEffect(() => {
if (isDragging) {
// 드래그 중에는 텍스트 선택 방지
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.body.style.userSelect = "";
document.body.style.cursor = "";
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<div
ref={containerRef}
style={{
...(isPreview
? {
position: "relative",
height: `${component.style?.height || 600}px`,
border: "1px solid #e5e7eb",
}
: componentStyle),
display: "flex",
flexDirection: "row",
}}
onClick={(e) => {
if (isDesignMode) {
e.stopPropagation();
onClick?.(e);
}
}}
className="w-full overflow-hidden rounded-lg bg-white shadow-sm"
>
{/* 좌측 패널 */}
<div
style={{ width: `${leftWidth}%`, minWidth: isPreview ? "0" : `${minLeftWidth}px`, height: "100%" }}
className="border-border flex flex-shrink-0 flex-col border-r"
>
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader className="border-b pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">
{componentConfig.leftPanel?.title || "좌측 패널"}
</CardTitle>
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button
size="sm"
variant="outline"
onClick={() => handleAddClick("left")}
>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
</div>
{componentConfig.leftPanel?.showSearch && (
<div className="relative mt-2">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="검색..."
value={leftSearchQuery}
onChange={(e) => setLeftSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-auto p-4">
{/* 좌측 데이터 목록/테이블 */}
{componentConfig.leftPanel?.displayMode === "table" ? (
// 테이블 모드
<div className="w-full">
{isDesignMode ? (
// 디자인 모드: 샘플 테이블
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 1</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 2</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 3</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
<tr className="hover:bg-gray-50 cursor-pointer">
<td className="whitespace-nowrap px-3 py-2 text-sm"> 1-1</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 1-2</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 1-3</td>
</tr>
<tr className="hover:bg-gray-50 cursor-pointer">
<td className="whitespace-nowrap px-3 py-2 text-sm"> 2-1</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 2-2</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 2-3</td>
</tr>
</tbody>
</table>
</div>
) : isLoadingLeft ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : (
(() => {
const 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;
const displayColumns = componentConfig.leftPanel?.columns || [];
const columnsToShow = displayColumns.length > 0
? displayColumns.map(col => ({
...col,
label: leftColumnLabels[col.name] || col.label || col.name
}))
: 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
}));
return (
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 bg-gray-50 z-10">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
const itemId = item[sourceColumn] || item.id || item.ID || idx;
const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return (
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{item[col.name] !== null && item[col.name] !== undefined
? String(item[col.name])
: "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
})()
)}
</div>
) : (
// 목록 모드 (기존)
<div className="space-y-1">
{isDesignMode ? (
// 디자인 모드: 샘플 데이터
<>
<div
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 1</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 2</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 3</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
</>
) : isLoadingLeft ? (
// 로딩 중
<div className="flex items-center justify-center py-8">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : (
(() => {
// 검색 필터링 (클라이언트 사이드)
const 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;
// 재귀 렌더링 함수
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;
// 조인에 사용하는 leftColumn을 필수로 표시
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
let displayFields: { label: string; value: any }[] = [];
// 디버그 로그
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],
});
// 추가로 다른 의미있는 필드 1-2개 표시 (name, title 등)
const additionalKeys = Object.keys(item).filter(
(k) => k !== "id" && k !== "ID" && k !== leftColumn &&
(k.includes("name") || k.includes("title") || k.includes("desc"))
);
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);
}
}
const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`;
const displaySubtitle = displayFields[1]?.value || null;
return (
<React.Fragment key={itemId}>
{/* 현재 항목 */}
<div
className={`group relative cursor-pointer rounded-md p-3 transition-colors hover:bg-muted ${
isSelected ? "bg-primary/10 text-primary" : "text-foreground"
}`}
style={{ paddingLeft: `${12 + level * 24}px` }}
>
<div
className="flex items-center gap-2"
onClick={() => {
handleLeftItemSelect(item);
if (hasChildren) {
toggleExpand(itemId);
}
}}
>
{/* 펼치기/접기 아이콘 */}
{hasChildren ? (
<div className="flex-shrink-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</div>
) : (
<div className="w-5" />
)}
{/* 항목 내용 */}
<div className="flex-1 min-w-0">
<div className="truncate font-medium">{displayTitle}</div>
{displaySubtitle && <div className="truncate text-xs text-muted-foreground">{displaySubtitle}</div>}
</div>
{/* 항목별 버튼들 */}
{!isDesignMode && (
<div className="flex flex-shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{/* 수정 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("left", item);
}}
className="rounded p-1 hover:bg-gray-200 transition-colors"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
{/* 삭제 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("left", item);
}}
className="rounded p-1 hover:bg-red-100 transition-colors"
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 hover:bg-gray-200 transition-colors"
title="하위 항목 추가"
>
<Plus className="h-4 w-4 text-gray-600" />
</button>
)}
</div>
)}
</div>
</div>
{/* 자식 항목들 (접혀있으면 표시 안함) */}
{hasChildren && isExpanded && item.children.map((child: any, childIndex: number) => renderTreeItem(child, childIndex))}
</React.Fragment>
);
};
return filteredLeftData.length > 0 ? (
// 실제 데이터 표시
filteredLeftData.map((item, index) => renderTreeItem(item, index))
) : (
// 검색 결과 없음
<div className="py-8 text-center text-sm text-muted-foreground">
{leftSearchQuery ? (
<>
<p> .</p>
<p className="mt-1 text-xs text-muted-foreground/70"> .</p>
</>
) : (
"데이터가 없습니다."
)}
</div>
);
})()
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* 리사이저 */}
{resizable && (
<div
onMouseDown={handleMouseDown}
className="group flex w-1 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary"
>
<GripVertical className="h-4 w-4 text-muted-foreground group-hover:text-primary-foreground" />
</div>
)}
{/* 우측 패널 */}
<div
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px`, height: "100%" }}
className="flex flex-shrink-0 flex-col"
>
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader className="border-b pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"}
</CardTitle>
{!isDesignMode && (
<div className="flex items-center gap-2">
{componentConfig.rightPanel?.showAdd && (
<Button
size="sm"
variant="outline"
onClick={() => handleAddClick("right")}
>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
</div>
)}
</div>
{componentConfig.rightPanel?.showSearch && (
<div className="relative mt-2">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="검색..."
value={rightSearchQuery}
onChange={(e) => setRightSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터 */}
{isLoadingRight ? (
// 로딩 중
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
<p className="mt-2 text-sm text-muted-foreground"> ...</p>
</div>
</div>
) : rightData ? (
// 실제 데이터 표시
Array.isArray(rightData) ? (
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
(() => {
// 검색 필터링
const filteredData = rightSearchQuery
? rightData.filter((item) => {
const searchLower = rightSearchQuery.toLowerCase();
return Object.entries(item).some(([key, value]) => {
if (value === null || value === undefined) return false;
return String(value).toLowerCase().includes(searchLower);
});
})
: rightData;
// 테이블 모드 체크
const isTableMode = componentConfig.rightPanel?.displayMode === "table";
if (isTableMode) {
// 테이블 모드 렌더링
const displayColumns = componentConfig.rightPanel?.columns || [];
const columnsToShow = displayColumns.length > 0
? displayColumns.map(col => ({
...col,
label: rightColumnLabels[col.name] || col.label || col.name
}))
: Object.keys(filteredData[0] || {}).filter(key => !key.toLowerCase().includes("password")).slice(0, 5).map(key => ({
name: key,
label: rightColumnLabels[key] || key,
width: 150,
align: "left" as const
}));
return (
<div className="w-full">
<div className="mb-2 text-xs text-muted-foreground">
{filteredData.length}
{rightSearchQuery && filteredData.length !== rightData.length && (
<span className="ml-1 text-primary">( {rightData.length} )</span>
)}
</div>
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 bg-gray-50 z-10">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
>
{col.label}
</th>
))}
{!isDesignMode && (
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase"></th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => {
const itemId = item.id || item.ID || idx;
return (
<tr
key={itemId}
className="hover:bg-accent transition-colors"
>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{item[col.name] !== null && item[col.name] !== undefined
? String(item[col.name])
: "-"}
</td>
))}
{!isDesignMode && (
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
<div className="flex justify-end gap-1">
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="rounded p-1 hover:bg-gray-200 transition-colors"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
className="rounded p-1 hover:bg-red-100 transition-colors"
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// 목록 모드 (기존)
return filteredData.length > 0 ? (
<div className="space-y-2">
<div className="mb-2 text-xs text-muted-foreground">
{filteredData.length}
{rightSearchQuery && filteredData.length !== rightData.length && (
<span className="ml-1 text-primary">( {rightData.length} )</span>
)}
</div>
{filteredData.map((item, index) => {
const itemId = item.id || item.ID || index;
const isExpanded = expandedRightItems.has(itemId);
// 우측 패널 표시 컬럼 설정 확인
const rightColumns = componentConfig.rightPanel?.columns;
let firstValues: [string, any][] = [];
let allValues: [string, any][] = [];
if (index === 0) {
console.log("🔍 우측 패널 표시 로직:");
console.log(" - rightColumns:", rightColumns);
console.log(" - item keys:", Object.keys(item));
}
if (rightColumns && rightColumns.length > 0) {
// 설정된 컬럼만 표시
firstValues = rightColumns
.slice(0, 3)
.map((col) => [col.name, item[col.name]] as [string, any])
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
allValues = rightColumns
.map((col) => [col.name, item[col.name]] as [string, any])
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
if (index === 0) {
console.log(" ✅ 설정된 컬럼 사용:", rightColumns.map(c => c.name));
}
} else {
// 설정 없으면 모든 컬럼 표시 (기존 로직)
firstValues = Object.entries(item)
.filter(([key]) => !key.toLowerCase().includes("id"))
.slice(0, 3);
allValues = Object.entries(item).filter(
([key, value]) => value !== null && value !== undefined && value !== "",
);
if (index === 0) {
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
}
}
return (
<div
key={itemId}
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
>
{/* 요약 정보 */}
<div className="p-3">
<div className="flex items-start justify-between gap-2">
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => toggleRightItemExpansion(itemId)}
>
{firstValues.map(([key, value], idx) => (
<div key={key} className="mb-1 last:mb-0">
<div className="text-xs font-medium text-muted-foreground">{getColumnLabel(key)}</div>
<div className="truncate text-sm text-foreground" title={String(value || "-")}>
{String(value || "-")}
</div>
</div>
))}
</div>
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
{/* 수정 버튼 */}
{!isDesignMode && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="rounded p-1 hover:bg-gray-200 transition-colors"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
)}
{/* 삭제 버튼 */}
{!isDesignMode && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
className="rounded p-1 hover:bg-red-100 transition-colors"
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
)}
{/* 확장/접기 버튼 */}
<button
onClick={() => toggleRightItemExpansion(itemId)}
className="rounded p-1 hover:bg-gray-200 transition-colors"
>
{isExpanded ? (
<ChevronUp className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
)}
</button>
</div>
</div>
</div>
{/* 상세 정보 (확장 시 표시) */}
{isExpanded && (
<div className="bg-muted/50 border-t px-3 py-2">
<div className="mb-2 text-xs font-semibold"> </div>
<div className="bg-card overflow-auto rounded-md border">
<table className="w-full text-sm">
<tbody className="divide-y divide-border">
{allValues.map(([key, value]) => (
<tr key={key} className="hover:bg-muted">
<td className="px-3 py-2 font-medium whitespace-nowrap text-muted-foreground">
{getColumnLabel(key)}
</td>
<td className="px-3 py-2 break-all text-foreground">{String(value)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
{rightSearchQuery ? (
<>
<p> .</p>
<p className="mt-1 text-xs text-muted-foreground/70"> .</p>
</>
) : (
"관련 데이터가 없습니다."
)}
</div>
);
})()
) : (
// 상세 모드: 단일 객체를 상세 정보로 표시
(() => {
const rightColumns = componentConfig.rightPanel?.columns;
let displayEntries: [string, any][] = [];
if (rightColumns && rightColumns.length > 0) {
// 설정된 컬럼만 표시
displayEntries = rightColumns
.map((col) => [col.name, rightData[col.name]] as [string, any])
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
console.log("🔍 상세 모드 표시 로직:");
console.log(" ✅ 설정된 컬럼 사용:", rightColumns.map(c => c.name));
} else {
// 설정 없으면 모든 컬럼 표시
displayEntries = Object.entries(rightData).filter(
([_, value]) => value !== null && value !== undefined && value !== ""
);
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
}
return (
<div className="space-y-2">
{displayEntries.map(([key, value]) => (
<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">
{getColumnLabel(key)}
</div>
<div className="text-sm">{String(value)}</div>
</div>
))}
</div>
);
})()
)
) : selectedLeftItem && isDesignMode ? (
// 디자인 모드: 샘플 데이터
<div className="space-y-4">
<div className="rounded-lg border p-4">
<h3 className="mb-2 font-medium">{selectedLeftItem.name} </h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"> 1:</span>
<span className="font-medium"> 1</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> 2:</span>
<span className="font-medium"> 2</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> 3:</span>
<span className="font-medium"> 3</span>
</div>
</div>
</div>
</div>
) : (
// 선택 없음
<div className="flex h-full items-center justify-center">
<div className="text-center text-sm text-muted-foreground">
<p className="mb-2"> </p>
<p className="text-xs"> </p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* 추가 모달 */}
<Dialog open={showAddModal} onOpenChange={setShowAddModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{addModalPanel === "left"
? `${componentConfig.leftPanel?.title} 추가`
: addModalPanel === "right"
? `${componentConfig.rightPanel?.title} 추가`
: `하위 ${componentConfig.leftPanel?.title} 추가`}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{addModalPanel === "left-item"
? "선택한 항목의 하위 항목을 추가합니다. 필수 항목을 입력해주세요."
: "새로운 데이터를 추가합니다. 필수 항목을 입력해주세요."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{(() => {
// 어떤 컬럼들을 표시할지 결정
let modalColumns: Array<{ name: string; label: string; required?: boolean }> | undefined;
if (addModalPanel === "left") {
modalColumns = componentConfig.leftPanel?.addModalColumns;
} else if (addModalPanel === "right") {
modalColumns = componentConfig.rightPanel?.addModalColumns;
} else if (addModalPanel === "left-item") {
modalColumns = componentConfig.leftPanel?.itemAddConfig?.addModalColumns;
}
return modalColumns?.map((col, index) => {
// 항목별 추가 버튼으로 열렸을 때, parentColumn은 미리 채워져 있고 수정 불가
const isItemAddPreFilled = addModalPanel === "left-item"
&& componentConfig.leftPanel?.itemAddConfig?.parentColumn === col.name
&& addModalFormData[col.name];
// 우측 패널 추가 시, 조인 컬럼(rightColumn)은 미리 채워져 있고 수정 불가
const isRightJoinPreFilled = addModalPanel === "right"
&& componentConfig.rightPanel?.rightColumn === col.name
&& addModalFormData[col.name];
const isPreFilled = isItemAddPreFilled || isRightJoinPreFilled;
return (
<div key={index}>
<Label htmlFor={col.name} className="text-xs sm:text-sm">
{col.label} {col.required && <span className="text-destructive">*</span>}
{isPreFilled && <span className="ml-2 text-[10px] text-blue-600">( )</span>}
</Label>
<Input
id={col.name}
value={addModalFormData[col.name] || ""}
onChange={(e) => {
setAddModalFormData(prev => ({
...prev,
[col.name]: e.target.value
}));
}}
placeholder={`${col.label} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
required={col.required}
disabled={isPreFilled}
/>
</div>
);
});
})()}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowAddModal(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleAddModalSave}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Save className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 수정 모달 */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editModalPanel === "left"
? `${componentConfig.leftPanel?.title} 수정`
: `${componentConfig.rightPanel?.title} 수정`}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{editModalItem && (() => {
// 좌측 패널 수정: leftColumn만 수정 가능
if (editModalPanel === "left") {
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
// leftColumn만 표시
if (!leftColumn || editModalFormData[leftColumn] === undefined) {
return <p className="text-sm text-muted-foreground"> .</p>;
}
return (
<div>
<Label htmlFor={`edit-${leftColumn}`} className="text-xs sm:text-sm">
{leftColumn}
</Label>
<Input
id={`edit-${leftColumn}`}
value={editModalFormData[leftColumn] || ""}
onChange={(e) => {
setEditModalFormData(prev => ({
...prev,
[leftColumn]: e.target.value
}));
}}
placeholder={`${leftColumn} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
);
}
// 우측 패널 수정: 우측 패널에 설정된 표시 컬럼들만
if (editModalPanel === "right") {
const rightColumns = componentConfig.rightPanel?.columns;
if (rightColumns && rightColumns.length > 0) {
// 설정된 컬럼만 표시
return rightColumns.map((col) => (
<div key={col.name}>
<Label htmlFor={`edit-${col.name}`} className="text-xs sm:text-sm">
{col.label || col.name}
</Label>
<Input
id={`edit-${col.name}`}
value={editModalFormData[col.name] || ""}
onChange={(e) => {
setEditModalFormData(prev => ({
...prev,
[col.name]: e.target.value
}));
}}
placeholder={`${col.label || col.name} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
));
} else {
// 설정이 없으면 모든 컬럼 표시 (company_code, company_name 제외)
return Object.entries(editModalFormData)
.filter(([key]) => key !== 'company_code' && key !== 'company_name')
.map(([key, value]) => (
<div key={key}>
<Label htmlFor={`edit-${key}`} className="text-xs sm:text-sm">
{key}
</Label>
<Input
id={`edit-${key}`}
value={editModalFormData[key] || ""}
onChange={(e) => {
setEditModalFormData(prev => ({
...prev,
[key]: e.target.value
}));
}}
placeholder={`${key} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
));
}
}
return null;
})()}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowEditModal(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleEditModalSave}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Save className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 모달 */}
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
?
<br /> .
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowDeleteModal(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Trash2 className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
/**
* SplitPanelLayout 래퍼 컴포넌트
*/
export const SplitPanelLayoutWrapper: React.FC<SplitPanelLayoutComponentProps> = (props) => {
return <SplitPanelLayoutComponent {...props} />;
};