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

757 lines
28 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, useCallback } from "react";
import { ComponentRendererProps } from "@/types/component";
import { CardDisplayConfig } from "./types";
import { tableTypeApi } from "@/lib/api/screen";
import { getFullImageUrl } 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 { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { useModalDataStore } from "@/stores/modalDataStore";
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 screenContext = useScreenContextOptional();
const splitPanelContext = useSplitPanelContext();
const splitPanelPosition = screenContext?.splitPanelPosition;
// 테이블 데이터 상태 관리
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게)
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
// 상세보기 모달 상태
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(() => {
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (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 [];
}, [componentConfig.dataSource, 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<string>();
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);
console.log("[CardDisplay] modalDataStore에 데이터 저장:", {
dataSourceId: tableNameToUse,
count: modalItems.length,
});
} else if (tableNameToUse && selectedRowsData.length === 0) {
useModalDataStore.getState().clearData(tableNameToUse);
console.log("[CardDisplay] modalDataStore 데이터 제거:", tableNameToUse);
}
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
if (splitPanelContext && splitPanelPosition === "left") {
if (checked) {
splitPanelContext.setSelectedLeftData(data);
console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", {
data,
parentDataMapping: splitPanelContext.parentDataMapping,
});
} else {
splitPanelContext.setSelectedLeftData(null);
console.log("[CardDisplay] 분할 패널 좌측 데이터 초기화");
}
}
}, [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]);
// 로딩 중인 경우 로딩 표시
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: "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 getColumnValue = (data: any, columnName?: string) => {
if (!columnName) return "";
return data[columnName] || "";
};
// 컬럼명을 라벨로 변환하는 헬퍼 함수
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 (
<>
<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");
// 이미지 컬럼 자동 감지 (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 (
<div
key={cardKey}
style={{
...cardStyle,
borderColor: isCardSelected ? "#000" : "#e5e7eb",
borderWidth: isCardSelected ? "2px" : "1px",
boxShadow: isCardSelected
? "0 4px 6px -1px rgba(0, 0, 0, 0.15)"
: "0 1px 3px rgba(0, 0, 0, 0.08)",
flexDirection: "row", // 가로 배치
}}
className="card-hover group cursor-pointer transition-all duration-150"
onClick={() => handleCardClick(data, index)}
>
{/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */}
{shouldShowImage && (
<div className="flex-shrink-0 flex items-center justify-center mr-4">
{imageUrl ? (
<img
src={imageUrl}
alt={titleValue || "이미지"}
className="h-16 w-16 rounded-lg object-cover border border-gray-200"
onError={(e) => {
// 이미지 로드 실패 시 기본 아이콘으로 대체
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";
}}
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10">
<span className="text-2xl text-primary">👤</span>
</div>
)}
</div>
)}
{/* 우측 컨텐츠 영역 */}
<div className="flex flex-col flex-1 min-w-0">
{/* 타이틀 + 서브타이틀 */}
{(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && (
<div className="mb-1 flex items-center gap-2 flex-wrap">
{componentConfig.cardStyle?.showTitle && (
<h3 className="text-base font-semibold text-foreground leading-tight">{titleValue}</h3>
)}
{componentConfig.cardStyle?.showSubtitle && subtitleValue && (
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">{subtitleValue}</span>
)}
</div>
)}
{/* 추가 표시 컬럼들 - 가로 배치 */}
{componentConfig.columnMapping?.displayColumns &&
componentConfig.columnMapping.displayColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
const value = getColumnValue(data, columnName);
if (!value) return null;
return (
<div key={idx} className="flex items-center gap-1">
<span>{getColumnLabel(columnName)}:</span>
<span className="font-medium text-foreground">{value}</span>
</div>
);
})}
</div>
)}
{/* 카드 설명 */}
{componentConfig.cardStyle?.showDescription && descriptionValue && (
<div className="mt-1 flex-1">
<p className="text-xs text-muted-foreground leading-relaxed">
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
</p>
</div>
)}
{/* 카드 액션 */}
<div className="mt-2 flex justify-end space-x-2">
<button
className="text-xs text-blue-600 hover:text-blue-800 transition-colors"
onClick={(e) => {
e.stopPropagation();
handleCardView(data);
}}
>
</button>
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
handleCardEdit(data);
}}
>
</button>
</div>
</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>
</>
);
};