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

606 lines
22 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",
};
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
// 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정
if (isDesignMode) {
componentStyle.border = "1px dashed hsl(var(--border))";
componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))";
}
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
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-muted-foreground"> ...</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: "2px solid #e5e7eb", // 더 명확한 테두리
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)",
borderColor: "#f59e0b", // 호버 시 오렌지 테두리
}
};
// 텍스트 자르기 함수
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: "hsl(var(--muted-foreground))",
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-primary/10 to-primary/20 shadow-sm border-2 border-background">
<span className="text-2xl text-primary">👤</span>
</div>
</div>
)}
{/* 카드 타이틀 - 통일된 디자인 */}
{componentConfig.cardStyle?.showTitle && (
<div className="mb-3">
<h3 className="text-xl font-bold text-foreground leading-tight">{titleValue}</h3>
</div>
)}
{/* 카드 서브타이틀 - 통일된 디자인 */}
{componentConfig.cardStyle?.showSubtitle && (
<div className="mb-3">
<p className="text-sm font-semibold text-primary bg-primary/10 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-foreground bg-muted 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-border 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-background/50 px-3 py-2 rounded-lg border border-border">
<span className="text-muted-foreground font-medium capitalize">{getColumnLabel(columnName)}:</span>
<span className="font-semibold text-foreground bg-muted 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-muted-foreground hover:text-foreground 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-muted rounded-lg p-3">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
{key.replace(/_/g, ' ')}
</div>
<div className="text-sm font-medium text-foreground 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-foreground bg-muted hover:bg-muted/80 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-foreground 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-primary hover:bg-primary/90"
>
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
};