ERP-node/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx

602 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
</>
);
};