408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState, useMemo } from "react";
|
|
import { ComponentRendererProps } from "@/types/component";
|
|
import { CardDisplayConfig } from "./types";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
|
|
export interface CardDisplayComponentProps extends ComponentRendererProps {
|
|
config?: CardDisplayConfig;
|
|
tableData?: any[];
|
|
tableColumns?: any[];
|
|
}
|
|
|
|
/**
|
|
* CardDisplay 컴포넌트
|
|
* 테이블 데이터를 카드 형태로 표시하는 컴포넌트
|
|
*/
|
|
export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|
component,
|
|
isDesignMode = false,
|
|
isSelected = false,
|
|
isInteractive = false,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
config,
|
|
className,
|
|
style,
|
|
formData,
|
|
onFormDataChange,
|
|
screenId,
|
|
tableName,
|
|
tableData = [],
|
|
tableColumns = [],
|
|
...props
|
|
}) => {
|
|
// 테이블 데이터 상태 관리
|
|
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
|
|
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// 테이블 데이터 로딩
|
|
useEffect(() => {
|
|
const loadTableData = async () => {
|
|
// 디자인 모드에서는 테이블 데이터를 로드하지 않음
|
|
if (isDesignMode) {
|
|
return;
|
|
}
|
|
|
|
// tableName 확인 (props에서 전달받은 tableName 사용)
|
|
const tableNameToUse = tableName || component.componentConfig?.tableName;
|
|
|
|
if (!tableNameToUse) {
|
|
console.log("📋 CardDisplay: 테이블명이 설정되지 않음", {
|
|
tableName,
|
|
componentTableName: component.componentConfig?.tableName,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
console.log(`📋 CardDisplay: ${tableNameToUse} 테이블 데이터 로딩 시작`);
|
|
|
|
// 테이블 데이터와 컬럼 정보를 병렬로 로드
|
|
const [dataResponse, columnsResponse] = await Promise.all([
|
|
tableTypeApi.getTableData(tableNameToUse, {
|
|
page: 1,
|
|
size: 50, // 카드 표시용으로 적당한 개수
|
|
}),
|
|
tableTypeApi.getColumns(tableNameToUse),
|
|
]);
|
|
|
|
console.log(`📋 CardDisplay: ${tableNameToUse} 데이터 로딩 완료`, {
|
|
total: dataResponse.total,
|
|
dataLength: dataResponse.data.length,
|
|
columnsLength: columnsResponse.length,
|
|
sampleData: dataResponse.data.slice(0, 2),
|
|
sampleColumns: columnsResponse.slice(0, 3),
|
|
});
|
|
|
|
setLoadedTableData(dataResponse.data);
|
|
setLoadedTableColumns(columnsResponse);
|
|
} catch (error) {
|
|
console.error(`❌ CardDisplay: ${tableNameToUse} 데이터 로딩 실패`, error);
|
|
setLoadedTableData([]);
|
|
setLoadedTableColumns([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadTableData();
|
|
}, [isDesignMode, tableName, component.componentConfig?.tableName]);
|
|
|
|
// 컴포넌트 설정 (기본값 보장)
|
|
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",
|
|
};
|
|
|
|
if (isDesignMode) {
|
|
componentStyle.border = "1px dashed #cbd5e1";
|
|
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
|
}
|
|
|
|
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
|
|
const displayData = useMemo(() => {
|
|
console.log("📋 CardDisplay: displayData 결정 중", {
|
|
dataSource: componentConfig.dataSource,
|
|
loadedTableDataLength: loadedTableData.length,
|
|
tableDataLength: tableData.length,
|
|
staticDataLength: componentConfig.staticData?.length || 0,
|
|
});
|
|
|
|
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시)
|
|
if (loadedTableData.length > 0) {
|
|
console.log("📋 CardDisplay: 로드된 테이블 데이터 사용", loadedTableData.slice(0, 2));
|
|
return loadedTableData;
|
|
}
|
|
|
|
// props로 전달받은 테이블 데이터가 있으면 사용
|
|
if (tableData.length > 0) {
|
|
console.log("📋 CardDisplay: props 테이블 데이터 사용", tableData.slice(0, 2));
|
|
return tableData;
|
|
}
|
|
|
|
if (componentConfig.staticData && componentConfig.staticData.length > 0) {
|
|
console.log("📋 CardDisplay: 정적 데이터 사용", componentConfig.staticData.slice(0, 2));
|
|
return componentConfig.staticData;
|
|
}
|
|
|
|
// 데이터가 없으면 빈 배열 반환
|
|
console.log("📋 CardDisplay: 표시할 데이터가 없음");
|
|
return [];
|
|
}, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]);
|
|
|
|
// 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용)
|
|
const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns;
|
|
|
|
// 로딩 중인 경우 로딩 표시
|
|
if (loading) {
|
|
return (
|
|
<div
|
|
className={className}
|
|
style={{
|
|
...componentStyle,
|
|
...style,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: "20px",
|
|
}}
|
|
>
|
|
<div className="text-gray-500">테이블 데이터를 로드하는 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 컨테이너 스타일 (원래 카드 레이아웃과 완전히 동일)
|
|
const containerStyle: React.CSSProperties = {
|
|
display: "grid",
|
|
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
|
|
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
|
|
gap: `${componentConfig.cardSpacing || 16}px`,
|
|
padding: "16px",
|
|
width: "100%",
|
|
height: "100%",
|
|
background: "transparent",
|
|
overflow: "auto",
|
|
};
|
|
|
|
// 카드 스타일 (원래 카드 레이아웃과 완전히 동일)
|
|
const cardStyle: React.CSSProperties = {
|
|
backgroundColor: "white",
|
|
border: "1px solid #e5e7eb",
|
|
borderRadius: "8px",
|
|
padding: "16px",
|
|
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
|
|
transition: "all 0.2s ease-in-out",
|
|
overflow: "hidden",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
position: "relative",
|
|
minHeight: "200px",
|
|
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 getColumnValue = (data: any, columnName?: string) => {
|
|
if (!columnName) return "";
|
|
return data[columnName] || "";
|
|
};
|
|
|
|
// 컬럼명을 라벨로 변환하는 헬퍼 함수
|
|
const getColumnLabel = (columnName: string) => {
|
|
if (!actualTableColumns || actualTableColumns.length === 0) return columnName;
|
|
const column = actualTableColumns.find((col) => col.columnName === columnName);
|
|
return column?.columnLabel || columnName;
|
|
};
|
|
|
|
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
|
|
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?.();
|
|
};
|
|
|
|
const handleCardClick = (data: any) => {
|
|
if (componentConfig.onCardClick) {
|
|
componentConfig.onCardClick(data);
|
|
}
|
|
};
|
|
|
|
// DOM에 전달하면 안 되는 React-specific props 필터링
|
|
const {
|
|
selectedScreen,
|
|
onZoneComponentDrop,
|
|
onZoneClick,
|
|
componentConfig: _componentConfig,
|
|
component: _component,
|
|
isSelected: _isSelected,
|
|
onClick: _onClick,
|
|
onDragStart: _onDragStart,
|
|
onDragEnd: _onDragEnd,
|
|
size: _size,
|
|
position: _position,
|
|
style: _style,
|
|
onRefresh: _onRefresh, // React DOM 속성이 아니므로 필터링
|
|
...domProps
|
|
} = props;
|
|
|
|
return (
|
|
<>
|
|
<style jsx>{`
|
|
.card-hover {
|
|
transition: all 0.2s ease-in-out;
|
|
}
|
|
.card-hover:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow:
|
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
border-color: #3b82f6;
|
|
}
|
|
`}</style>
|
|
<div
|
|
className={className}
|
|
style={{
|
|
...componentStyle,
|
|
...style,
|
|
}}
|
|
onClick={handleClick}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
{...domProps}
|
|
>
|
|
<div style={containerStyle}>
|
|
{displayData.length === 0 ? (
|
|
<div
|
|
style={{
|
|
gridColumn: "1 / -1",
|
|
textAlign: "center",
|
|
padding: "40px 20px",
|
|
color: "#6b7280",
|
|
fontSize: "14px",
|
|
}}
|
|
>
|
|
표시할 데이터가 없습니다.
|
|
</div>
|
|
) : (
|
|
displayData.map((data, index) => {
|
|
// 타이틀, 서브타이틀, 설명 값 결정 (원래 카드 레이아웃과 동일한 로직)
|
|
const titleValue =
|
|
getColumnValue(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title");
|
|
|
|
const subtitleValue =
|
|
getColumnValue(data, componentConfig.columnMapping?.subtitleColumn) ||
|
|
getAutoFallbackValue(data, "subtitle");
|
|
|
|
const descriptionValue =
|
|
getColumnValue(data, componentConfig.columnMapping?.descriptionColumn) ||
|
|
getAutoFallbackValue(data, "description");
|
|
|
|
const imageValue = componentConfig.columnMapping?.imageColumn
|
|
? getColumnValue(data, componentConfig.columnMapping.imageColumn)
|
|
: data.avatar || data.image || "";
|
|
|
|
return (
|
|
<div
|
|
key={data.id || index}
|
|
style={cardStyle}
|
|
className="card-hover"
|
|
onClick={() => handleCardClick(data)}
|
|
>
|
|
{/* 카드 이미지 */}
|
|
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (
|
|
<div className="mb-3 flex justify-center">
|
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
|
|
<span className="text-xl text-gray-500">👤</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 카드 타이틀 */}
|
|
{componentConfig.cardStyle?.showTitle && (
|
|
<div className="mb-2">
|
|
<h3 className="text-lg font-semibold text-gray-900">{titleValue}</h3>
|
|
</div>
|
|
)}
|
|
|
|
{/* 카드 서브타이틀 */}
|
|
{componentConfig.cardStyle?.showSubtitle && (
|
|
<div className="mb-2">
|
|
<p className="text-sm font-medium text-blue-600">{subtitleValue}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 카드 설명 */}
|
|
{componentConfig.cardStyle?.showDescription && (
|
|
<div className="mb-3 flex-1">
|
|
<p className="text-sm leading-relaxed text-gray-600">
|
|
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 추가 표시 컬럼들 */}
|
|
{componentConfig.columnMapping?.displayColumns &&
|
|
componentConfig.columnMapping.displayColumns.length > 0 && (
|
|
<div className="space-y-1 border-t border-gray-100 pt-3">
|
|
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
|
|
const value = getColumnValue(data, columnName);
|
|
if (!value) return null;
|
|
|
|
return (
|
|
<div key={idx} className="flex justify-between text-xs">
|
|
<span className="text-gray-500 capitalize">{getColumnLabel(columnName)}:</span>
|
|
<span className="font-medium text-gray-700">{value}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* 카드 액션 (선택사항) */}
|
|
<div className="mt-3 flex justify-end space-x-2">
|
|
<button className="text-xs font-medium text-blue-600 hover:text-blue-800">상세보기</button>
|
|
<button className="text-xs font-medium text-gray-500 hover:text-gray-700">편집</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|