"use client"; import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { ComponentRendererProps } from "@/types/component"; import { CardDisplayConfig } from "./types"; import { tableTypeApi } from "@/lib/api/screen"; import { getFullImageUrl, apiClient } from "@/lib/api/client"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useModalDataStore } from "@/stores/modalDataStore"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options"; export interface CardDisplayComponentProps extends ComponentRendererProps { config?: CardDisplayConfig; tableData?: any[]; tableColumns?: any[]; } /** * CardDisplay 컴포넌트 * 테이블 데이터를 카드 형태로 표시하는 컴포넌트 */ export const CardDisplayComponent: React.FC = ({ component, isDesignMode = false, isSelected = false, isInteractive = false, onClick, onDragStart, onDragEnd, config, className, style, formData, onFormDataChange, screenId, tableName, tableData = [], tableColumns = [], ...props }) => { // 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음) const screenContext = useScreenContextOptional(); const splitPanelContext = useSplitPanelContext(); const splitPanelPosition = screenContext?.splitPanelPosition; // TableOptions Context (검색 필터 위젯 연동용) let tableOptionsContext: ReturnType | null = null; try { tableOptionsContext = useTableOptions(); } catch (e) { // Context가 없으면 (디자이너 모드) 무시 } // 테이블 데이터 상태 관리 const [loadedTableData, setLoadedTableData] = useState([]); const [loadedTableColumns, setLoadedTableColumns] = useState([]); const [loading, setLoading] = useState(true); // 초기 로딩 상태를 true로 설정 const [initialLoadDone, setInitialLoadDone] = useState(false); // 초기 로드 완료 여부 const [hasEverSelectedLeftData, setHasEverSelectedLeftData] = useState(false); // 좌측 데이터 선택 이력 // 필터 상태 (검색 필터 위젯에서 전달받은 필터) const [filters, setFiltersInternal] = useState([]); // 필터 상태 변경 래퍼 const setFilters = useCallback((newFilters: TableFilter[]) => { setFiltersInternal(newFilters); }, []); // 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상) const [columnMeta, setColumnMeta] = useState< Record >({}); const [categoryMappings, setCategoryMappings] = useState< Record> >({}); // 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게) const [selectedRows, setSelectedRows] = useState>(new Set()); // 상세보기 모달 상태 const [viewModalOpen, setViewModalOpen] = useState(false); const [selectedData, setSelectedData] = useState(null); // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editData, setEditData] = useState(null); // 카드 액션 핸들러 const handleCardView = (data: any) => { // console.log("👀 상세보기 클릭:", data); setSelectedData(data); setViewModalOpen(true); }; const handleCardEdit = (data: any) => { // console.log("✏️ 편집 클릭:", data); setEditData({ ...data }); // 복사본 생성 setEditModalOpen(true); }; // 삭제 핸들러 const handleCardDelete = async (data: any, index: number) => { // 사용자 확인 if (!confirm("정말로 이 항목을 삭제하시겠습니까?")) { return; } try { const tableNameToUse = tableName || component.componentConfig?.tableName; if (!tableNameToUse) { alert("테이블 정보가 없습니다."); return; } // 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함) const deleteData = [data]; // API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정) // 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만 // axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용 const response = await apiClient.request({ method: 'DELETE', url: `/table-management/tables/${tableNameToUse}/delete`, data: deleteData, headers: { 'Content-Type': 'application/json', }, }); if (response.data.success) { alert("삭제되었습니다."); // 로컬 상태에서 삭제된 항목 제거 setLoadedTableData(prev => prev.filter((item, idx) => idx !== index)); // 선택된 항목이면 선택 해제 const cardKey = getCardKey(data, index); if (selectedRows.has(cardKey)) { const newSelectedRows = new Set(selectedRows); newSelectedRows.delete(cardKey); setSelectedRows(newSelectedRows); } } else { alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`); } } catch (error: any) { const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류"; alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`); } }; // 편집 폼 데이터 변경 핸들러 const handleEditFormChange = (key: string, value: string) => { setEditData((prev: any) => ({ ...prev, [key]: value })); }; // 편집 저장 핸들러 const handleEditSave = async () => { // console.log("💾 편집 저장:", editData); try { // TODO: 실제 API 호출로 데이터 업데이트 // await tableTypeApi.updateTableData(tableName, editData); // console.log("✅ 편집 저장 완료"); alert("✅ 저장되었습니다!"); // 모달 닫기 setEditModalOpen(false); setEditData(null); // 데이터 새로고침 (필요시) // loadTableData(); } catch (error) { alert("저장에 실패했습니다."); } }; // 테이블 데이터 로딩 useEffect(() => { const loadTableData = async () => { // 디자인 모드에서는 테이블 데이터를 로드하지 않음 if (isDesignMode) { setLoading(false); setInitialLoadDone(true); return; } // 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지) // splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음 const isRightPanelEarly = splitPanelPosition === "right"; const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; if (isRightPanelEarly && !hasSelectedLeftDataEarly) { // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) // 초기 로드가 아닌 경우에는 데이터를 지우지 않음 if (!initialLoadDone) { setLoadedTableData([]); } setLoading(false); setInitialLoadDone(true); return; } // tableName 확인 (props에서 전달받은 tableName 사용) const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정 if (!tableNameToUse) { setLoading(false); setInitialLoadDone(true); return; } // 연결 필터 확인 (분할 패널 내부일 때) let linkedFilterValues: Record = {}; let hasLinkedFiltersConfigured = false; let hasSelectedLeftData = false; if (splitPanelContext) { // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) const linkedFiltersConfig = splitPanelContext.linkedFilters || []; hasLinkedFiltersConfigured = linkedFiltersConfig.some( (filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") || filter.targetColumn === tableNameToUse ); // 좌측 데이터 선택 여부 확인 hasSelectedLeftData = splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; linkedFilterValues = splitPanelContext.getLinkedFilterValues(); // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 const tableSpecificFilters: Record = {}; for (const [key, value] of Object.entries(linkedFilterValues)) { // key가 "테이블명.컬럼명" 형식인 경우 if (key.includes(".")) { const [tblName, columnName] = key.split("."); if (tblName === tableNameToUse) { // 연결 필터는 코드 값이므로 equals 연산자 사용 tableSpecificFilters[columnName] = { value, operator: "equals" }; hasLinkedFiltersConfigured = true; } } else { // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals) tableSpecificFilters[key] = { value, operator: "equals" }; } } linkedFilterValues = tableSpecificFilters; } // 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 // 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수 // splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인 const isRightPanelFromContext = splitPanelPosition === "right"; const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId ? splitPanelContext.getPositionByScreenId(screenId as number) === "right" : false; const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext; const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) { setLoadedTableData([]); setLoading(false); setInitialLoadDone(true); return; } try { setLoading(true); // API 호출 파라미터에 연결 필터 추가 (search 객체 안에 넣어야 함) const apiParams: Record = { page: 1, size: 50, // 카드 표시용으로 적당한 개수 search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined, }; // 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드 const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([ tableTypeApi.getTableData(tableNameToUse, apiParams), tableTypeApi.getColumns(tableNameToUse), tableTypeApi.getColumnInputTypes(tableNameToUse), ]); setLoadedTableData(dataResponse.data); setLoadedTableColumns(columnsResponse); // 컬럼 메타 정보 설정 (inputType 포함) const meta: Record = {}; inputTypesResponse.forEach((item: any) => { meta[item.columnName || item.column_name] = { webType: item.webType || item.web_type, inputType: item.inputType || item.input_type, codeCategory: item.codeCategory || item.code_category, }; }); setColumnMeta(meta); // 카테고리 타입 컬럼 찾기 및 매핑 로드 const categoryColumns = Object.entries(meta) .filter(([_, m]) => m.inputType === "category") .map(([columnName]) => columnName); if (categoryColumns.length > 0) { const mappings: Record> = {}; for (const columnName of categoryColumns) { try { const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`); if (response.data.success && response.data.data) { const mapping: Record = {}; response.data.data.forEach((item: any) => { // API 응답 형식: valueCode, valueLabel (camelCase) const code = item.valueCode || item.value_code || item.category_code || item.code || item.value; const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code; // color가 null/undefined/"none"이면 undefined로 유지 (배지 없음) const rawColor = item.color ?? item.badge_color; const color = (rawColor && rawColor !== "none") ? rawColor : undefined; mapping[code] = { label, color }; }); mappings[columnName] = mapping; } } catch (error) { // 카테고리 매핑 로드 실패 시 무시 } } setCategoryMappings(mappings); } } catch (error) { setLoadedTableData([]); setLoadedTableColumns([]); } finally { setLoading(false); setInitialLoadDone(true); } }; loadTableData(); }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition]); // 컴포넌트 설정 (기본값 보장) const componentConfig = { cardsPerRow: 3, // 기본값 3 (한 행당 카드 수) cardSpacing: 16, cardStyle: { showTitle: true, showSubtitle: true, showDescription: true, showImage: false, showActions: true, maxDescriptionLength: 100, imagePosition: "top", imageSize: "medium", }, columnMapping: {}, dataSource: "table", staticData: [], ...config, ...component.config, ...component.componentConfig, } as CardDisplayConfig; // 컴포넌트 기본 스타일 const componentStyle: React.CSSProperties = { width: "100%", height: "100%", position: "relative", backgroundColor: "transparent", }; // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) // 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정 if (isDesignMode) { componentStyle.border = "1px dashed hsl(var(--border))"; componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))"; } // 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산) const isRightPanelForDisplay = splitPanelPosition === "right" || (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData; const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay && Object.keys(selectedLeftDataForDisplay).length > 0; // 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록 useEffect(() => { if (hasSelectedLeftDataForDisplay) { setHasEverSelectedLeftData(true); } }, [hasSelectedLeftDataForDisplay]); // 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 // 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지 const shouldHideDataForRightPanel = isRightPanelForDisplay && !hasEverSelectedLeftData && !hasSelectedLeftDataForDisplay; // 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용) const displayData = useMemo(() => { // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 빈 배열 반환 if (shouldHideDataForRightPanel) { return []; } // 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시) if (loadedTableData.length > 0) { return loadedTableData; } // props로 전달받은 테이블 데이터가 있으면 사용 if (tableData.length > 0) { return tableData; } if (componentConfig.staticData && componentConfig.staticData.length > 0) { return componentConfig.staticData; } // 데이터가 없으면 빈 배열 반환 return []; }, [shouldHideDataForRightPanel, loadedTableData, tableData, componentConfig.staticData]); // 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용) const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns; // 카드 ID 가져오기 함수 (훅은 조기 리턴 전에 선언) const getCardKey = useCallback((data: any, index: number): string => { return String(data.id || data.objid || data.ID || index); }, []); // 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제) const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => { // 단일 선택: 새로운 Set 생성 (기존 선택 초기화) const newSelectedRows = new Set(); if (checked) { // 선택 시 해당 카드만 선택 newSelectedRows.add(cardKey); } // checked가 false면 빈 Set (선택 해제) setSelectedRows(newSelectedRows); // 선택된 카드 데이터 계산 const selectedRowsData = displayData.filter((item, index) => newSelectedRows.has(getCardKey(item, index)) ); // onFormDataChange 호출 if (onFormDataChange) { onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData, }); } // modalDataStore에 선택된 데이터 저장 const tableNameToUse = componentConfig.dataSource?.tableName || tableName; if (tableNameToUse && selectedRowsData.length > 0) { const modalItems = selectedRowsData.map((row, idx) => ({ id: getCardKey(row, idx), originalData: row, additionalData: {}, })); useModalDataStore.getState().setData(tableNameToUse, modalItems); } else if (tableNameToUse && selectedRowsData.length === 0) { useModalDataStore.getState().clearData(tableNameToUse); } // 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { if (checked) { splitPanelContext.setSelectedLeftData(data); } else { splitPanelContext.setSelectedLeftData(null); } } }, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]); const handleCardClick = useCallback((data: any, index: number) => { const cardKey = getCardKey(data, index); const isCurrentlySelected = selectedRows.has(cardKey); // 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택 handleCardSelection(cardKey, data, !isCurrentlySelected); if (componentConfig.onCardClick) { componentConfig.onCardClick(data); } }, [getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick]); // DataProvidable 인터페이스 구현 (테이블 리스트와 동일) const dataProvider = useMemo(() => ({ componentId: component.id, componentType: "card-display" as const, getSelectedData: () => { const selectedData = displayData.filter((item, index) => selectedRows.has(getCardKey(item, index)) ); return selectedData; }, getAllData: () => { return displayData; }, clearSelection: () => { setSelectedRows(new Set()); }, }), [component.id, displayData, selectedRows, getCardKey]); // ScreenContext에 데이터 제공자로 등록 useEffect(() => { if (screenContext && component.id) { screenContext.registerDataProvider(component.id, dataProvider); return () => { screenContext.unregisterDataProvider(component.id); }; } }, [screenContext, component.id, dataProvider]); // TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용) const tableId = `card-display-${component.id}`; const tableNameToUse = tableName || component.componentConfig?.tableName || ''; const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이"; // ref로 최신 데이터 참조 (useCallback 의존성 문제 해결) const loadedTableDataRef = useRef(loadedTableData); const categoryMappingsRef = useRef(categoryMappings); useEffect(() => { loadedTableDataRef.current = loadedTableData; }, [loadedTableData]); useEffect(() => { categoryMappingsRef.current = categoryMappings; }, [categoryMappings]); // 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴) // 초기 로드 여부 추적 - 마운트 카운터 사용 (Strict Mode 대응) const mountCountRef = useRef(0); useEffect(() => { mountCountRef.current += 1; const currentMount = mountCountRef.current; if (!tableNameToUse || isDesignMode) return; // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵 const isRightPanel = splitPanelPosition === "right" || (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; const hasSelectedLeftData = splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) if (isRightPanel && !hasSelectedLeftData) { // 데이터를 지우지 않고 로딩만 false로 설정 setLoading(false); return; } // 첫 2번의 마운트는 초기 로드 useEffect에서 처리 (Strict Mode에서 2번 호출됨) // 필터 변경이 아닌 경우 스킵 if (currentMount <= 2 && filters.length === 0) { return; } const loadFilteredData = async () => { try { // 로딩 상태를 true로 설정하지 않음 - 기존 데이터 유지하면서 새 데이터 로드 (깜빡임 방지) // 필터 값을 검색 파라미터로 변환 const searchParams: Record = {}; filters.forEach(filter => { if (filter.value !== undefined && filter.value !== null && filter.value !== '') { searchParams[filter.columnName] = filter.value; } }); // search 파라미터로 검색 조건 전달 (API 스펙에 맞게) const dataResponse = await tableTypeApi.getTableData(tableNameToUse, { page: 1, size: 50, search: searchParams, }); setLoadedTableData(dataResponse.data); // 데이터 건수 업데이트 if (tableOptionsContext) { tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0); } } catch (error) { // 필터 적용 실패 시 무시 } }; // 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터) loadFilteredData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]); // 컬럼 고유 값 조회 함수 (select 타입 필터용) const getColumnUniqueValues = useCallback(async (columnName: string): Promise> => { if (!tableNameToUse) return []; try { // 현재 로드된 데이터에서 고유 값 추출 const uniqueValues = new Set(); loadedTableDataRef.current.forEach(row => { const value = row[columnName]; if (value !== null && value !== undefined && value !== '') { uniqueValues.add(String(value)); } }); // 카테고리 매핑이 있으면 라벨 적용 const mapping = categoryMappingsRef.current[columnName]; return Array.from(uniqueValues).map(value => ({ value, label: mapping?.[value]?.label || value, })); } catch (error) { return []; } }, [tableNameToUse]); // TableOptionsContext에 등록 // registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화) const registerTableRef = useRef(tableOptionsContext?.registerTable); const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable); // setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용) const setFiltersRef = useRef(setFiltersInternal); const getColumnUniqueValuesRef = useRef(getColumnUniqueValues); useEffect(() => { registerTableRef.current = tableOptionsContext?.registerTable; unregisterTableRef.current = tableOptionsContext?.unregisterTable; }, [tableOptionsContext]); useEffect(() => { setFiltersRef.current = setFiltersInternal; }, [setFiltersInternal]); useEffect(() => { getColumnUniqueValuesRef.current = getColumnUniqueValues; }, [getColumnUniqueValues]); // 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록) const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.columnName || col.column_name)); useEffect(() => { if (!registerTableRef.current || !unregisterTableRef.current) return; if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return; // 컬럼 정보를 TableColumn 형식으로 변환 const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({ columnName: col.columnName || col.column_name, columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text', visible: true, width: 200, sortable: true, filterable: true, })); // onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용 const onFilterChangeWrapper = (newFilters: TableFilter[]) => { setFiltersRef.current(newFilters); }; const getColumnUniqueValuesWrapper = async (columnName: string) => { return getColumnUniqueValuesRef.current(columnName); }; const registration = { tableId, label: tableLabel, tableName: tableNameToUse, columns, dataCount: loadedTableData.length, onFilterChange: onFilterChangeWrapper, onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원 onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원 getColumnUniqueValues: getColumnUniqueValuesWrapper, }; registerTableRef.current(registration); const unregister = unregisterTableRef.current; const currentTableId = tableId; return () => { unregister(currentTableId); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ isDesignMode, tableId, tableNameToUse, tableLabel, columnsKey, // 컬럼 변경 시에만 재등록 ]); // 우측 패널이고 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 // 한 번이라도 선택된 적이 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) if (shouldHideDataForRightPanel) { return (
좌측에서 항목을 선택해주세요
선택한 항목의 관련 데이터가 여기에 표시됩니다
); } // 로딩 중이고 데이터가 없는 경우에만 로딩 표시 // 데이터가 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) if (loading && displayData.length === 0 && !hasEverSelectedLeftData) { return (
테이블 데이터를 로드하는 중...
); } // 컨테이너 스타일 - 통일된 디자인 시스템 적용 const containerStyle: React.CSSProperties = { display: "grid", gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수) gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시 gap: `${componentConfig.cardSpacing || 32}px`, // 간격 대폭 증가로 여유로운 느낌 padding: "32px", // 패딩 대폭 증가 width: "100%", height: "100%", background: "#f8fafc", // 연한 하늘색 배경 (채도 낮춤) overflow: "auto", borderRadius: "12px", // 컨테이너 자체도 라운드 처리 }; // 카드 스타일 - 컴팩트한 디자인 const cardStyle: React.CSSProperties = { backgroundColor: "white", border: "1px solid #e5e7eb", borderRadius: "8px", padding: "16px", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)", transition: "all 0.2s ease", overflow: "hidden", display: "flex", flexDirection: "column", position: "relative", cursor: isDesignMode ? "pointer" : "default", }; // 텍스트 자르기 함수 const truncateText = (text: string, maxLength: number) => { if (!text) return ""; if (text.length <= maxLength) return text; return text.substring(0, maxLength) + "..."; }; // 컬럼 값을 문자열로 가져오기 (카테고리 타입인 경우 매핑된 라벨 반환) const getColumnValueAsString = (data: any, columnName?: string): string => { if (!columnName) return ""; const value = data[columnName]; if (value === null || value === undefined || value === "") return ""; // 카테고리 타입인 경우 매핑된 라벨 반환 const meta = columnMeta[columnName]; if (meta?.inputType === "category") { const mapping = categoryMappings[columnName]; const valueStr = String(value); const categoryData = mapping?.[valueStr]; return categoryData?.label || valueStr; } return String(value); }; // 컬럼 매핑에서 값 가져오기 (카테고리 타입인 경우 배지로 표시) const getColumnValue = (data: any, columnName?: string): React.ReactNode => { if (!columnName) return ""; const value = data[columnName]; if (value === null || value === undefined || value === "") return ""; // 카테고리 타입인 경우 매핑된 라벨과 배지로 표시 const meta = columnMeta[columnName]; if (meta?.inputType === "category") { const mapping = categoryMappings[columnName]; const valueStr = String(value); const categoryData = mapping?.[valueStr]; const displayLabel = categoryData?.label || valueStr; const displayColor = categoryData?.color; // 색상이 없거나(null/undefined), 빈 문자열이거나, "none"이면 일반 텍스트로 표시 (배지 없음) if (!displayColor || displayColor === "none") { return displayLabel; } return ( {displayLabel} ); } return String(value); }; // 컬럼명을 라벨로 변환하는 헬퍼 함수 const getColumnLabel = (columnName: string) => { if (!actualTableColumns || actualTableColumns.length === 0) { // 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환 return formatColumnName(columnName); } const column = actualTableColumns.find( (col) => col.columnName === columnName || col.column_name === columnName ); // 다양한 라벨 필드명 지원 (displayName이 API에서 반환하는 라벨) const label = column?.displayName || column?.columnLabel || column?.column_label || column?.label; return label || formatColumnName(columnName); }; // 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분) const formatColumnName = (columnName: string) => { // 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화 return columnName .replace(/_/g, ' ') .replace(/\b\w/g, (char) => char.toUpperCase()); }; // 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기 const getAutoFallbackValue = (data: any, type: "title" | "subtitle" | "description") => { const keys = Object.keys(data); switch (type) { case "title": // 이름 관련 필드 우선 검색 return data.name || data.title || data.label || data[keys[0]] || "제목 없음"; case "subtitle": // 직책, 부서, 카테고리 관련 필드 검색 return data.position || data.role || data.department || data.category || data.type || ""; case "description": // 설명, 내용 관련 필드 검색 return data.description || data.content || data.summary || data.memo || ""; default: return ""; } }; // 이벤트 핸들러 const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); }; // DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용) const safeDomProps = filterDOMProps(props); return ( <>
{displayData.length === 0 ? (
표시할 데이터가 없습니다.
) : ( displayData.map((data, index) => { // 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시) const titleValue = getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title"); const subtitleValue = getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) || getAutoFallbackValue(data, "subtitle"); const descriptionValue = getColumnValueAsString(data, componentConfig.columnMapping?.descriptionColumn) || getAutoFallbackValue(data, "description"); // 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시 const imageColumn = componentConfig.columnMapping?.imageColumn || Object.keys(data).find(key => { const lowerKey = key.toLowerCase(); return lowerKey.includes('image') || lowerKey.includes('photo') || lowerKey.includes('avatar') || lowerKey.includes('thumbnail') || lowerKey.includes('picture') || lowerKey.includes('img'); }); // 이미지 값 가져오기 (직접 접근 + 폴백) const imageValue = imageColumn ? data[imageColumn] : (data.image_path || data.imagePath || data.avatar || data.image || data.photo || ""); // 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우 const shouldShowImage = componentConfig.cardStyle?.showImage !== false; // 이미지 URL 생성 (TableListComponent와 동일한 로직 사용) const imageUrl = imageValue ? getFullImageUrl(imageValue) : ""; const cardKey = getCardKey(data, index); const isCardSelected = selectedRows.has(cardKey); return (
handleCardClick(data, index)} > {/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */} {shouldShowImage && (
{imageUrl ? ( {titleValue { // 이미지 로드 실패 시 기본 아이콘으로 대체 e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E"; }} /> ) : (
👤
)}
)} {/* 우측 컨텐츠 영역 */}
{/* 타이틀 + 서브타이틀 */} {(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && (
{componentConfig.cardStyle?.showTitle && (

{titleValue}

)} {componentConfig.cardStyle?.showSubtitle && subtitleValue && ( {subtitleValue} )}
)} {/* 추가 표시 컬럼들 - 가로 배치 */} {componentConfig.columnMapping?.displayColumns && componentConfig.columnMapping.displayColumns.length > 0 && (
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => { const value = getColumnValue(data, columnName); if (!value) return null; return (
{getColumnLabel(columnName)}: {value}
); })}
)} {/* 카드 설명 */} {componentConfig.cardStyle?.showDescription && descriptionValue && (

{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}

)} {/* 카드 액션 - 설정에 따라 표시 */} {(componentConfig.cardStyle?.showActions ?? true) && (
{(componentConfig.cardStyle?.showViewButton ?? true) && ( )} {(componentConfig.cardStyle?.showEditButton ?? true) && ( )} {(componentConfig.cardStyle?.showDeleteButton ?? false) && ( )}
)}
); }) )}
{/* 상세보기 모달 */} 📋 상세 정보 {selectedData && (
{Object.entries(selectedData) .filter(([key, value]) => value !== null && value !== undefined && value !== '') .map(([key, value]) => { // 카테고리 타입인 경우 배지로 표시 const meta = columnMeta[key]; let displayValue: React.ReactNode = String(value); if (meta?.inputType === "category") { const mapping = categoryMappings[key]; const valueStr = String(value); const categoryData = mapping?.[valueStr]; const displayLabel = categoryData?.label || valueStr; const displayColor = categoryData?.color; // 색상이 있고 "none"이 아닌 경우에만 배지로 표시 if (displayColor && displayColor !== "none") { displayValue = ( {displayLabel} ); } else { // 배지 없음: 일반 텍스트로 표시 displayValue = displayLabel; } } return (
{getColumnLabel(key)}
{displayValue}
); }) }
)}
{/* 편집 모달 */} ✏️ 데이터 편집 {editData && (
{Object.entries(editData) .filter(([key, value]) => value !== null && value !== undefined) .map(([key, value]) => (
handleEditFormChange(key, e.target.value)} className="w-full" placeholder={`${key} 입력`} />
)) }
)}
); };