602 lines
21 KiB
TypeScript
602 lines
21 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";
|
||
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";
|
||
|
||
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);
|
||
|
||
// 상세보기 모달 상태
|
||
const [viewModalOpen, setViewModalOpen] = useState(false);
|
||
const [selectedData, setSelectedData] = useState<any>(null);
|
||
|
||
// 편집 모달 상태
|
||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||
const [editData, setEditData] = useState<any>(null);
|
||
|
||
// 카드 액션 핸들러
|
||
const handleCardView = (data: any) => {
|
||
// console.log("👀 상세보기 클릭:", data);
|
||
setSelectedData(data);
|
||
setViewModalOpen(true);
|
||
};
|
||
|
||
const handleCardEdit = (data: any) => {
|
||
// console.log("✏️ 편집 클릭:", data);
|
||
setEditData({ ...data }); // 복사본 생성
|
||
setEditModalOpen(true);
|
||
};
|
||
|
||
// 편집 폼 데이터 변경 핸들러
|
||
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) {
|
||
console.error("❌ 편집 저장 실패:", error);
|
||
alert("❌ 저장에 실패했습니다.");
|
||
}
|
||
};
|
||
|
||
// 테이블 데이터 로딩
|
||
useEffect(() => {
|
||
const loadTableData = async () => {
|
||
// 디자인 모드에서는 테이블 데이터를 로드하지 않음
|
||
if (isDesignMode) {
|
||
return;
|
||
}
|
||
|
||
// tableName 확인 (props에서 전달받은 tableName 사용)
|
||
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
||
|
||
if (!tableNameToUse) {
|
||
// console.log("📋 CardDisplay: 테이블명이 설정되지 않음", {
|
||
// tableName,
|
||
// componentTableName: component.componentConfig?.tableName,
|
||
// });
|
||
return;
|
||
}
|
||
|
||
// console.log("📋 CardDisplay: 사용할 테이블명", {
|
||
// tableName,
|
||
// componentTableName: component.componentConfig?.tableName,
|
||
// finalTableName: tableNameToUse,
|
||
// });
|
||
|
||
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 || 32}px`, // 간격 대폭 증가로 여유로운 느낌
|
||
padding: "32px", // 패딩 대폭 증가
|
||
width: "100%",
|
||
height: "100%",
|
||
background: "#f8fafc", // 연한 하늘색 배경 (채도 낮춤)
|
||
overflow: "auto",
|
||
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
|
||
};
|
||
|
||
// 카드 스타일 - 통일된 디자인 시스템 적용
|
||
const cardStyle: React.CSSProperties = {
|
||
backgroundColor: "white",
|
||
border: "1px solid #e2e8f0", // 더 부드러운 보더 색상
|
||
borderRadius: "12px", // 통일된 라운드 처리
|
||
padding: "24px", // 더 여유로운 패딩
|
||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", // 더 깊은 그림자
|
||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", // 부드러운 트랜지션
|
||
overflow: "hidden",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
position: "relative",
|
||
minHeight: "240px", // 최소 높이 더 증가
|
||
cursor: isDesignMode ? "pointer" : "default",
|
||
// 호버 효과를 위한 추가 스타일
|
||
"&:hover": {
|
||
transform: "translateY(-2px)",
|
||
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
||
}
|
||
};
|
||
|
||
// 텍스트 자르기 함수
|
||
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 안전한 props만 필터링 (filterDOMProps 유틸리티 사용)
|
||
const safeDomProps = filterDOMProps(props);
|
||
|
||
return (
|
||
<>
|
||
<style jsx>{`
|
||
.card-hover {
|
||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.card-hover::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||
transition: left 0.6s ease;
|
||
}
|
||
.card-hover:hover::before {
|
||
left: 100%;
|
||
}
|
||
.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;
|
||
}
|
||
.card-container {
|
||
position: relative;
|
||
}
|
||
.card-container::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: transparent;
|
||
border-radius: 12px;
|
||
pointer-events: none;
|
||
}
|
||
`}</style>
|
||
<div
|
||
className={className}
|
||
style={{
|
||
...componentStyle,
|
||
...style,
|
||
}}
|
||
onClick={handleClick}
|
||
onDragStart={onDragStart}
|
||
onDragEnd={onDragEnd}
|
||
{...safeDomProps}
|
||
>
|
||
<div style={containerStyle} className="card-container">
|
||
{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 group cursor-pointer"
|
||
onClick={() => handleCardClick(data)}
|
||
>
|
||
{/* 카드 이미지 - 통일된 디자인 */}
|
||
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (
|
||
<div className="mb-4 flex justify-center">
|
||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-blue-100 to-indigo-100 shadow-sm border-2 border-white">
|
||
<span className="text-2xl text-blue-600">👤</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 카드 타이틀 - 통일된 디자인 */}
|
||
{componentConfig.cardStyle?.showTitle && (
|
||
<div className="mb-3">
|
||
<h3 className="text-xl font-bold text-gray-900 leading-tight">{titleValue}</h3>
|
||
</div>
|
||
)}
|
||
|
||
{/* 카드 서브타이틀 - 통일된 디자인 */}
|
||
{componentConfig.cardStyle?.showSubtitle && (
|
||
<div className="mb-3">
|
||
<p className="text-sm font-semibold text-blue-600 bg-blue-50 px-3 py-1 rounded-full inline-block">{subtitleValue}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 카드 설명 - 통일된 디자인 */}
|
||
{componentConfig.cardStyle?.showDescription && (
|
||
<div className="mb-4 flex-1">
|
||
<p className="text-sm leading-relaxed text-gray-700 bg-gray-50 p-3 rounded-lg">
|
||
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 추가 표시 컬럼들 - 통일된 디자인 */}
|
||
{componentConfig.columnMapping?.displayColumns &&
|
||
componentConfig.columnMapping.displayColumns.length > 0 && (
|
||
<div className="space-y-2 border-t border-gray-200 pt-4">
|
||
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
|
||
const value = getColumnValue(data, columnName);
|
||
if (!value) return null;
|
||
|
||
return (
|
||
<div key={idx} className="flex justify-between items-center text-sm bg-white/50 px-3 py-2 rounded-lg border border-gray-100">
|
||
<span className="text-gray-600 font-medium capitalize">{getColumnLabel(columnName)}:</span>
|
||
<span className="font-semibold text-gray-900 bg-gray-100 px-2 py-1 rounded-md text-xs">{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 transition-colors"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleCardView(data);
|
||
}}
|
||
>
|
||
상세보기
|
||
</button>
|
||
<button
|
||
className="text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleCardEdit(data);
|
||
}}
|
||
>
|
||
편집
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 상세보기 모달 */}
|
||
<Dialog open={viewModalOpen} onOpenChange={setViewModalOpen}>
|
||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<span className="text-lg">📋</span>
|
||
상세 정보
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{selectedData && (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{Object.entries(selectedData)
|
||
.filter(([key, value]) => value !== null && value !== undefined && value !== '')
|
||
.map(([key, value]) => (
|
||
<div key={key} className="bg-gray-50 rounded-lg p-3">
|
||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
|
||
{key.replace(/_/g, ' ')}
|
||
</div>
|
||
<div className="text-sm font-medium text-gray-900 break-words">
|
||
{String(value)}
|
||
</div>
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
|
||
<div className="flex justify-end pt-4 border-t">
|
||
<button
|
||
onClick={() => setViewModalOpen(false)}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||
>
|
||
닫기
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 편집 모달 */}
|
||
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
|
||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<span className="text-lg">✏️</span>
|
||
데이터 편집
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{editData && (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-1 gap-4">
|
||
{Object.entries(editData)
|
||
.filter(([key, value]) => value !== null && value !== undefined)
|
||
.map(([key, value]) => (
|
||
<div key={key} className="space-y-2">
|
||
<label className="text-sm font-medium text-gray-700 block">
|
||
{key.replace(/_/g, ' ').toUpperCase()}
|
||
</label>
|
||
<Input
|
||
type="text"
|
||
value={String(value)}
|
||
onChange={(e) => handleEditFormChange(key, e.target.value)}
|
||
className="w-full"
|
||
placeholder={`${key} 입력`}
|
||
/>
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setEditModalOpen(false);
|
||
setEditData(null);
|
||
}}
|
||
>
|
||
취소
|
||
</Button>
|
||
<Button
|
||
onClick={handleEditSave}
|
||
className="bg-blue-600 hover:bg-blue-700"
|
||
>
|
||
저장
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
};
|