2025-09-15 17:10:46 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2025-12-03 18:48:23 +09:00
|
|
|
|
import React, { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
2025-09-15 17:10:46 +09:00
|
|
|
|
import { ComponentRendererProps } from "@/types/component";
|
|
|
|
|
|
import { CardDisplayConfig } from "./types";
|
|
|
|
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
2025-12-03 16:39:47 +09:00
|
|
|
|
import { getFullImageUrl, apiClient } from "@/lib/api/client";
|
2025-09-25 18:54:25 +09:00
|
|
|
|
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";
|
2025-12-03 16:39:47 +09:00
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
2025-12-02 18:03:52 +09:00
|
|
|
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
|
|
|
|
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
|
|
|
|
|
import { useModalDataStore } from "@/stores/modalDataStore";
|
2025-12-03 18:48:23 +09:00
|
|
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
|
|
|
|
import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options";
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}) => {
|
2025-12-02 18:03:52 +09:00
|
|
|
|
// 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음)
|
|
|
|
|
|
const screenContext = useScreenContextOptional();
|
|
|
|
|
|
const splitPanelContext = useSplitPanelContext();
|
|
|
|
|
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
|
|
|
|
|
|
2025-12-03 18:48:23 +09:00
|
|
|
|
// TableOptions Context (검색 필터 위젯 연동용)
|
|
|
|
|
|
let tableOptionsContext: ReturnType<typeof useTableOptions> | null = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
tableOptionsContext = useTableOptions();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// Context가 없으면 (디자이너 모드) 무시
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-15 17:10:46 +09:00
|
|
|
|
// 테이블 데이터 상태 관리
|
|
|
|
|
|
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
|
|
|
|
|
|
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
2025-12-16 11:49:10 +09:00
|
|
|
|
const [loading, setLoading] = useState(true); // 초기 로딩 상태를 true로 설정
|
|
|
|
|
|
const [initialLoadDone, setInitialLoadDone] = useState(false); // 초기 로드 완료 여부
|
|
|
|
|
|
const [hasEverSelectedLeftData, setHasEverSelectedLeftData] = useState(false); // 좌측 데이터 선택 이력
|
2025-12-03 16:39:47 +09:00
|
|
|
|
|
2025-12-03 18:48:23 +09:00
|
|
|
|
// 필터 상태 (검색 필터 위젯에서 전달받은 필터)
|
|
|
|
|
|
const [filters, setFiltersInternal] = useState<TableFilter[]>([]);
|
|
|
|
|
|
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 필터 상태 변경 래퍼
|
2025-12-03 18:48:23 +09:00
|
|
|
|
const setFilters = useCallback((newFilters: TableFilter[]) => {
|
|
|
|
|
|
setFiltersInternal(newFilters);
|
2025-12-16 11:49:10 +09:00
|
|
|
|
}, []);
|
2025-12-03 18:48:23 +09:00
|
|
|
|
|
2025-12-03 16:39:47 +09:00
|
|
|
|
// 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상)
|
|
|
|
|
|
const [columnMeta, setColumnMeta] = useState<
|
|
|
|
|
|
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
|
|
|
|
|
>({});
|
|
|
|
|
|
const [categoryMappings, setCategoryMappings] = useState<
|
|
|
|
|
|
Record<string, Record<string, { label: string; color?: string }>>
|
|
|
|
|
|
>({});
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
2025-12-02 18:03:52 +09:00
|
|
|
|
// 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게)
|
|
|
|
|
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
2025-12-01 18:39:01 +09:00
|
|
|
|
|
2025-09-25 18:54:25 +09:00
|
|
|
|
// 상세보기 모달 상태
|
|
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-15 18:29:18 +09:00
|
|
|
|
// 삭제 핸들러
|
|
|
|
|
|
const handleCardDelete = async (data: any, index: number) => {
|
|
|
|
|
|
// 사용자 확인
|
|
|
|
|
|
if (!confirm("정말로 이 항목을 삭제하시겠습니까?")) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const tableNameToUse = tableName || component.componentConfig?.tableName;
|
|
|
|
|
|
if (!tableNameToUse) {
|
|
|
|
|
|
alert("테이블 정보가 없습니다.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함)
|
|
|
|
|
|
const deleteData = [data];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정)
|
|
|
|
|
|
// 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만
|
|
|
|
|
|
// axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용
|
|
|
|
|
|
const response = await apiClient.request({
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
url: `/table-management/tables/${tableNameToUse}/delete`,
|
|
|
|
|
|
data: deleteData,
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.data.success) {
|
|
|
|
|
|
alert("삭제되었습니다.");
|
|
|
|
|
|
|
|
|
|
|
|
// 로컬 상태에서 삭제된 항목 제거
|
|
|
|
|
|
setLoadedTableData(prev => prev.filter((item, idx) => idx !== index));
|
|
|
|
|
|
|
|
|
|
|
|
// 선택된 항목이면 선택 해제
|
|
|
|
|
|
const cardKey = getCardKey(data, index);
|
|
|
|
|
|
if (selectedRows.has(cardKey)) {
|
|
|
|
|
|
const newSelectedRows = new Set(selectedRows);
|
|
|
|
|
|
newSelectedRows.delete(cardKey);
|
|
|
|
|
|
setSelectedRows(newSelectedRows);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류";
|
|
|
|
|
|
alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-25 18:54:25 +09:00
|
|
|
|
// 편집 폼 데이터 변경 핸들러
|
|
|
|
|
|
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) {
|
2025-12-16 11:49:10 +09:00
|
|
|
|
alert("저장에 실패했습니다.");
|
2025-09-25 18:54:25 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-15 17:10:46 +09:00
|
|
|
|
// 테이블 데이터 로딩
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const loadTableData = async () => {
|
|
|
|
|
|
// 디자인 모드에서는 테이블 데이터를 로드하지 않음
|
|
|
|
|
|
if (isDesignMode) {
|
2025-12-16 11:49:10 +09:00
|
|
|
|
setLoading(false);
|
|
|
|
|
|
setInitialLoadDone(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지)
|
|
|
|
|
|
// splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음
|
|
|
|
|
|
const isRightPanelEarly = splitPanelPosition === "right";
|
|
|
|
|
|
const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData &&
|
|
|
|
|
|
Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (isRightPanelEarly && !hasSelectedLeftDataEarly) {
|
|
|
|
|
|
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
|
|
|
|
|
|
// 초기 로드가 아닌 경우에는 데이터를 지우지 않음
|
|
|
|
|
|
if (!initialLoadDone) {
|
|
|
|
|
|
setLoadedTableData([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
setInitialLoadDone(true);
|
2025-09-15 17:10:46 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// tableName 확인 (props에서 전달받은 tableName 사용)
|
2025-09-25 18:54:25 +09:00
|
|
|
|
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
|
|
|
|
|
if (!tableNameToUse) {
|
2025-12-16 11:49:10 +09:00
|
|
|
|
setLoading(false);
|
|
|
|
|
|
setInitialLoadDone(true);
|
2025-09-15 17:10:46 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 18:29:18 +09:00
|
|
|
|
// 연결 필터 확인 (분할 패널 내부일 때)
|
|
|
|
|
|
let linkedFilterValues: Record<string, any> = {};
|
|
|
|
|
|
let hasLinkedFiltersConfigured = false;
|
|
|
|
|
|
let hasSelectedLeftData = false;
|
|
|
|
|
|
|
|
|
|
|
|
if (splitPanelContext) {
|
|
|
|
|
|
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
|
|
|
|
|
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
|
|
|
|
|
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
|
|
|
|
|
(filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") ||
|
|
|
|
|
|
filter.targetColumn === tableNameToUse
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 좌측 데이터 선택 여부 확인
|
|
|
|
|
|
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
|
|
|
|
|
|
Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
|
|
|
|
|
|
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
2025-12-16 10:46:43 +09:00
|
|
|
|
// 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함
|
2025-12-15 18:29:18 +09:00
|
|
|
|
const tableSpecificFilters: Record<string, any> = {};
|
|
|
|
|
|
for (const [key, value] of Object.entries(linkedFilterValues)) {
|
|
|
|
|
|
// key가 "테이블명.컬럼명" 형식인 경우
|
|
|
|
|
|
if (key.includes(".")) {
|
|
|
|
|
|
const [tblName, columnName] = key.split(".");
|
|
|
|
|
|
if (tblName === tableNameToUse) {
|
2025-12-16 10:46:43 +09:00
|
|
|
|
// 연결 필터는 코드 값이므로 equals 연산자 사용
|
|
|
|
|
|
tableSpecificFilters[columnName] = { value, operator: "equals" };
|
2025-12-15 18:29:18 +09:00
|
|
|
|
hasLinkedFiltersConfigured = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2025-12-16 10:46:43 +09:00
|
|
|
|
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals)
|
|
|
|
|
|
tableSpecificFilters[key] = { value, operator: "equals" };
|
2025-12-15 18:29:18 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
linkedFilterValues = tableSpecificFilters;
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시
|
|
|
|
|
|
// 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수
|
|
|
|
|
|
// splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인
|
|
|
|
|
|
const isRightPanelFromContext = splitPanelPosition === "right";
|
|
|
|
|
|
const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId
|
|
|
|
|
|
? splitPanelContext.getPositionByScreenId(screenId as number) === "right"
|
|
|
|
|
|
: false;
|
|
|
|
|
|
const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext;
|
|
|
|
|
|
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) {
|
2025-12-15 18:29:18 +09:00
|
|
|
|
setLoadedTableData([]);
|
|
|
|
|
|
setLoading(false);
|
2025-12-16 11:49:10 +09:00
|
|
|
|
setInitialLoadDone(true);
|
2025-12-15 18:29:18 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-15 17:10:46 +09:00
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
|
2025-12-15 18:29:18 +09:00
|
|
|
|
// API 호출 파라미터에 연결 필터 추가 (search 객체 안에 넣어야 함)
|
|
|
|
|
|
const apiParams: Record<string, any> = {
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
size: 50, // 카드 표시용으로 적당한 개수
|
|
|
|
|
|
search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-03 16:39:47 +09:00
|
|
|
|
// 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드
|
|
|
|
|
|
const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([
|
2025-12-15 18:29:18 +09:00
|
|
|
|
tableTypeApi.getTableData(tableNameToUse, apiParams),
|
2025-09-15 17:10:46 +09:00
|
|
|
|
tableTypeApi.getColumns(tableNameToUse),
|
2025-12-03 16:39:47 +09:00
|
|
|
|
tableTypeApi.getColumnInputTypes(tableNameToUse),
|
2025-09-15 17:10:46 +09:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
setLoadedTableData(dataResponse.data);
|
|
|
|
|
|
setLoadedTableColumns(columnsResponse);
|
2025-12-03 16:39:47 +09:00
|
|
|
|
|
|
|
|
|
|
// 컬럼 메타 정보 설정 (inputType 포함)
|
|
|
|
|
|
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
|
|
|
|
|
inputTypesResponse.forEach((item: any) => {
|
|
|
|
|
|
meta[item.columnName || item.column_name] = {
|
|
|
|
|
|
webType: item.webType || item.web_type,
|
|
|
|
|
|
inputType: item.inputType || item.input_type,
|
|
|
|
|
|
codeCategory: item.codeCategory || item.code_category,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
setColumnMeta(meta);
|
|
|
|
|
|
|
|
|
|
|
|
// 카테고리 타입 컬럼 찾기 및 매핑 로드
|
|
|
|
|
|
const categoryColumns = Object.entries(meta)
|
|
|
|
|
|
.filter(([_, m]) => m.inputType === "category")
|
|
|
|
|
|
.map(([columnName]) => columnName);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (categoryColumns.length > 0) {
|
|
|
|
|
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
|
|
|
|
|
|
|
|
|
|
|
for (const columnName of categoryColumns) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (response.data.success && response.data.data) {
|
|
|
|
|
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
|
|
|
|
|
response.data.data.forEach((item: any) => {
|
|
|
|
|
|
// API 응답 형식: valueCode, valueLabel (camelCase)
|
|
|
|
|
|
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
|
|
|
|
|
|
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
|
|
|
|
|
|
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
|
|
|
|
|
|
const rawColor = item.color ?? item.badge_color;
|
|
|
|
|
|
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
|
|
|
|
|
|
mapping[code] = { label, color };
|
|
|
|
|
|
});
|
|
|
|
|
|
mappings[columnName] = mapping;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 카테고리 매핑 로드 실패 시 무시
|
2025-12-03 16:39:47 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setCategoryMappings(mappings);
|
|
|
|
|
|
}
|
2025-09-15 17:10:46 +09:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setLoadedTableData([]);
|
|
|
|
|
|
setLoadedTableColumns([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
2025-12-16 11:49:10 +09:00
|
|
|
|
setInitialLoadDone(true);
|
2025-09-15 17:10:46 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
loadTableData();
|
2025-12-16 11:49:10 +09:00
|
|
|
|
}, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition]);
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 설정 (기본값 보장)
|
|
|
|
|
|
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",
|
|
|
|
|
|
};
|
2025-11-04 16:17:19 +09:00
|
|
|
|
|
|
|
|
|
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
|
|
|
|
|
// 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
|
|
|
|
|
if (isDesignMode) {
|
2025-10-30 15:39:39 +09:00
|
|
|
|
componentStyle.border = "1px dashed hsl(var(--border))";
|
|
|
|
|
|
componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))";
|
2025-09-15 17:10:46 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산)
|
|
|
|
|
|
const isRightPanelForDisplay = splitPanelPosition === "right" ||
|
|
|
|
|
|
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
|
|
|
|
|
|
const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
|
|
|
|
|
|
const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData;
|
|
|
|
|
|
const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay &&
|
|
|
|
|
|
Object.keys(selectedLeftDataForDisplay).length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (hasSelectedLeftDataForDisplay) {
|
|
|
|
|
|
setHasEverSelectedLeftData(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [hasSelectedLeftDataForDisplay]);
|
|
|
|
|
|
|
|
|
|
|
|
// 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시
|
|
|
|
|
|
// 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지
|
|
|
|
|
|
const shouldHideDataForRightPanel = isRightPanelForDisplay &&
|
|
|
|
|
|
!hasEverSelectedLeftData &&
|
|
|
|
|
|
!hasSelectedLeftDataForDisplay;
|
|
|
|
|
|
|
2025-09-15 17:10:46 +09:00
|
|
|
|
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
|
|
|
|
|
|
const displayData = useMemo(() => {
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 빈 배열 반환
|
|
|
|
|
|
if (shouldHideDataForRightPanel) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-15 17:10:46 +09:00
|
|
|
|
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (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 [];
|
2025-12-16 11:49:10 +09:00
|
|
|
|
}, [shouldHideDataForRightPanel, loadedTableData, tableData, componentConfig.staticData]);
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
|
|
|
|
|
// 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용)
|
|
|
|
|
|
const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns;
|
|
|
|
|
|
|
2025-12-02 18:03:52 +09:00
|
|
|
|
// 카드 ID 가져오기 함수 (훅은 조기 리턴 전에 선언)
|
|
|
|
|
|
const getCardKey = useCallback((data: any, index: number): string => {
|
|
|
|
|
|
return String(data.id || data.objid || data.ID || index);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제)
|
2025-12-02 18:03:52 +09:00
|
|
|
|
const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => {
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 단일 선택: 새로운 Set 생성 (기존 선택 초기화)
|
|
|
|
|
|
const newSelectedRows = new Set<string>();
|
|
|
|
|
|
|
2025-12-02 18:03:52 +09:00
|
|
|
|
if (checked) {
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 선택 시 해당 카드만 선택
|
2025-12-02 18:03:52 +09:00
|
|
|
|
newSelectedRows.add(cardKey);
|
|
|
|
|
|
}
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// checked가 false면 빈 Set (선택 해제)
|
|
|
|
|
|
|
2025-12-02 18:03:52 +09:00
|
|
|
|
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);
|
|
|
|
|
|
} else if (tableNameToUse && selectedRowsData.length === 0) {
|
|
|
|
|
|
useModalDataStore.getState().clearData(tableNameToUse);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
2025-12-05 14:08:07 +09:00
|
|
|
|
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
|
|
|
|
|
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
2025-12-02 18:03:52 +09:00
|
|
|
|
if (checked) {
|
|
|
|
|
|
splitPanelContext.setSelectedLeftData(data);
|
2025-12-03 16:02:09 +09:00
|
|
|
|
} else {
|
2025-12-02 18:03:52 +09:00
|
|
|
|
splitPanelContext.setSelectedLeftData(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-03 16:02:09 +09:00
|
|
|
|
}, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
|
2025-12-02 18:03:52 +09:00
|
|
|
|
|
|
|
|
|
|
const handleCardClick = useCallback((data: any, index: number) => {
|
|
|
|
|
|
const cardKey = getCardKey(data, index);
|
|
|
|
|
|
const isCurrentlySelected = selectedRows.has(cardKey);
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택
|
2025-12-02 18:03:52 +09:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-12-03 18:48:23 +09:00
|
|
|
|
// TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용)
|
|
|
|
|
|
const tableId = `card-display-${component.id}`;
|
|
|
|
|
|
const tableNameToUse = tableName || component.componentConfig?.tableName || '';
|
|
|
|
|
|
const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이";
|
|
|
|
|
|
|
|
|
|
|
|
// ref로 최신 데이터 참조 (useCallback 의존성 문제 해결)
|
|
|
|
|
|
const loadedTableDataRef = useRef(loadedTableData);
|
|
|
|
|
|
const categoryMappingsRef = useRef(categoryMappings);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadedTableDataRef.current = loadedTableData;
|
|
|
|
|
|
}, [loadedTableData]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
categoryMappingsRef.current = categoryMappings;
|
|
|
|
|
|
}, [categoryMappings]);
|
|
|
|
|
|
|
|
|
|
|
|
// 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴)
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 초기 로드 여부 추적 - 마운트 카운터 사용 (Strict Mode 대응)
|
|
|
|
|
|
const mountCountRef = useRef(0);
|
2025-12-03 18:48:23 +09:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-12-16 11:49:10 +09:00
|
|
|
|
mountCountRef.current += 1;
|
|
|
|
|
|
const currentMount = mountCountRef.current;
|
|
|
|
|
|
|
2025-12-03 18:48:23 +09:00
|
|
|
|
if (!tableNameToUse || isDesignMode) return;
|
|
|
|
|
|
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵
|
|
|
|
|
|
const isRightPanel = splitPanelPosition === "right" ||
|
|
|
|
|
|
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
|
|
|
|
|
|
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
|
|
|
|
|
|
const hasSelectedLeftData = splitPanelContext?.selectedLeftData &&
|
|
|
|
|
|
Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
|
|
|
|
|
|
if (isRightPanel && !hasSelectedLeftData) {
|
|
|
|
|
|
// 데이터를 지우지 않고 로딩만 false로 설정
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 첫 2번의 마운트는 초기 로드 useEffect에서 처리 (Strict Mode에서 2번 호출됨)
|
|
|
|
|
|
// 필터 변경이 아닌 경우 스킵
|
|
|
|
|
|
if (currentMount <= 2 && filters.length === 0) {
|
2025-12-03 18:48:23 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loadFilteredData = async () => {
|
|
|
|
|
|
try {
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 로딩 상태를 true로 설정하지 않음 - 기존 데이터 유지하면서 새 데이터 로드 (깜빡임 방지)
|
2025-12-03 18:48:23 +09:00
|
|
|
|
|
|
|
|
|
|
// 필터 값을 검색 파라미터로 변환
|
|
|
|
|
|
const searchParams: Record<string, any> = {};
|
|
|
|
|
|
filters.forEach(filter => {
|
|
|
|
|
|
if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
|
|
|
|
|
|
searchParams[filter.columnName] = filter.value;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// search 파라미터로 검색 조건 전달 (API 스펙에 맞게)
|
|
|
|
|
|
const dataResponse = await tableTypeApi.getTableData(tableNameToUse, {
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
size: 50,
|
|
|
|
|
|
search: searchParams,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setLoadedTableData(dataResponse.data);
|
|
|
|
|
|
|
|
|
|
|
|
// 데이터 건수 업데이트
|
|
|
|
|
|
if (tableOptionsContext) {
|
|
|
|
|
|
tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 필터 적용 실패 시 무시
|
2025-12-03 18:48:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터)
|
|
|
|
|
|
loadFilteredData();
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2025-12-16 11:49:10 +09:00
|
|
|
|
}, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]);
|
2025-12-03 18:48:23 +09:00
|
|
|
|
|
|
|
|
|
|
// 컬럼 고유 값 조회 함수 (select 타입 필터용)
|
|
|
|
|
|
const getColumnUniqueValues = useCallback(async (columnName: string): Promise<Array<{ label: string; value: string }>> => {
|
|
|
|
|
|
if (!tableNameToUse) return [];
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 현재 로드된 데이터에서 고유 값 추출
|
|
|
|
|
|
const uniqueValues = new Set<string>();
|
|
|
|
|
|
loadedTableDataRef.current.forEach(row => {
|
|
|
|
|
|
const value = row[columnName];
|
|
|
|
|
|
if (value !== null && value !== undefined && value !== '') {
|
|
|
|
|
|
uniqueValues.add(String(value));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 카테고리 매핑이 있으면 라벨 적용
|
|
|
|
|
|
const mapping = categoryMappingsRef.current[columnName];
|
|
|
|
|
|
return Array.from(uniqueValues).map(value => ({
|
|
|
|
|
|
value,
|
|
|
|
|
|
label: mapping?.[value]?.label || value,
|
|
|
|
|
|
}));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [tableNameToUse]);
|
|
|
|
|
|
|
|
|
|
|
|
// TableOptionsContext에 등록
|
|
|
|
|
|
// registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화)
|
|
|
|
|
|
const registerTableRef = useRef(tableOptionsContext?.registerTable);
|
|
|
|
|
|
const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable);
|
|
|
|
|
|
|
|
|
|
|
|
// setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용)
|
|
|
|
|
|
const setFiltersRef = useRef(setFiltersInternal);
|
|
|
|
|
|
const getColumnUniqueValuesRef = useRef(getColumnUniqueValues);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
registerTableRef.current = tableOptionsContext?.registerTable;
|
|
|
|
|
|
unregisterTableRef.current = tableOptionsContext?.unregisterTable;
|
|
|
|
|
|
}, [tableOptionsContext]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setFiltersRef.current = setFiltersInternal;
|
|
|
|
|
|
}, [setFiltersInternal]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
getColumnUniqueValuesRef.current = getColumnUniqueValues;
|
|
|
|
|
|
}, [getColumnUniqueValues]);
|
|
|
|
|
|
|
|
|
|
|
|
// 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록)
|
|
|
|
|
|
const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.columnName || col.column_name));
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!registerTableRef.current || !unregisterTableRef.current) return;
|
|
|
|
|
|
if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 컬럼 정보를 TableColumn 형식으로 변환
|
|
|
|
|
|
const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({
|
|
|
|
|
|
columnName: col.columnName || col.column_name,
|
|
|
|
|
|
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
|
|
|
|
|
inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text',
|
|
|
|
|
|
visible: true,
|
|
|
|
|
|
width: 200,
|
|
|
|
|
|
sortable: true,
|
|
|
|
|
|
filterable: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
// onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용
|
|
|
|
|
|
const onFilterChangeWrapper = (newFilters: TableFilter[]) => {
|
|
|
|
|
|
setFiltersRef.current(newFilters);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getColumnUniqueValuesWrapper = async (columnName: string) => {
|
|
|
|
|
|
return getColumnUniqueValuesRef.current(columnName);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const registration = {
|
|
|
|
|
|
tableId,
|
|
|
|
|
|
label: tableLabel,
|
|
|
|
|
|
tableName: tableNameToUse,
|
|
|
|
|
|
columns,
|
|
|
|
|
|
dataCount: loadedTableData.length,
|
|
|
|
|
|
onFilterChange: onFilterChangeWrapper,
|
|
|
|
|
|
onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원
|
|
|
|
|
|
onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원
|
|
|
|
|
|
getColumnUniqueValues: getColumnUniqueValuesWrapper,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
registerTableRef.current(registration);
|
|
|
|
|
|
|
|
|
|
|
|
const unregister = unregisterTableRef.current;
|
|
|
|
|
|
const currentTableId = tableId;
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
unregister(currentTableId);
|
|
|
|
|
|
};
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [
|
|
|
|
|
|
isDesignMode,
|
|
|
|
|
|
tableId,
|
|
|
|
|
|
tableNameToUse,
|
|
|
|
|
|
tableLabel,
|
|
|
|
|
|
columnsKey, // 컬럼 변경 시에만 재등록
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2025-12-16 11:49:10 +09:00
|
|
|
|
// 우측 패널이고 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시
|
|
|
|
|
|
// 한 번이라도 선택된 적이 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지)
|
|
|
|
|
|
if (shouldHideDataForRightPanel) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={className}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
...componentStyle,
|
|
|
|
|
|
...style,
|
|
|
|
|
|
display: "flex",
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
|
padding: "20px",
|
|
|
|
|
|
background: "#f8fafc",
|
|
|
|
|
|
borderRadius: "12px",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="text-muted-foreground text-center">
|
|
|
|
|
|
<div className="text-lg mb-2">좌측에서 항목을 선택해주세요</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">선택한 항목의 관련 데이터가 여기에 표시됩니다</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 로딩 중이고 데이터가 없는 경우에만 로딩 표시
|
|
|
|
|
|
// 데이터가 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지)
|
|
|
|
|
|
if (loading && displayData.length === 0 && !hasEverSelectedLeftData) {
|
2025-09-15 17:10:46 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={className}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
...componentStyle,
|
|
|
|
|
|
...style,
|
|
|
|
|
|
display: "flex",
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
|
padding: "20px",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-10-30 15:39:39 +09:00
|
|
|
|
<div className="text-muted-foreground">테이블 데이터를 로드하는 중...</div>
|
2025-09-15 17:10:46 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-29 17:21:47 +09:00
|
|
|
|
// 컨테이너 스타일 - 통일된 디자인 시스템 적용
|
2025-09-15 17:10:46 +09:00
|
|
|
|
const containerStyle: React.CSSProperties = {
|
|
|
|
|
|
display: "grid",
|
|
|
|
|
|
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
|
|
|
|
|
|
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
|
2025-09-29 17:21:47 +09:00
|
|
|
|
gap: `${componentConfig.cardSpacing || 32}px`, // 간격 대폭 증가로 여유로운 느낌
|
|
|
|
|
|
padding: "32px", // 패딩 대폭 증가
|
2025-09-15 17:10:46 +09:00
|
|
|
|
width: "100%",
|
|
|
|
|
|
height: "100%",
|
2025-09-30 10:30:05 +09:00
|
|
|
|
background: "#f8fafc", // 연한 하늘색 배경 (채도 낮춤)
|
2025-09-15 17:10:46 +09:00
|
|
|
|
overflow: "auto",
|
2025-09-29 17:21:47 +09:00
|
|
|
|
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
|
2025-09-15 17:10:46 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-01 18:39:01 +09:00
|
|
|
|
// 카드 스타일 - 컴팩트한 디자인
|
2025-09-15 17:10:46 +09:00
|
|
|
|
const cardStyle: React.CSSProperties = {
|
|
|
|
|
|
backgroundColor: "white",
|
2025-12-01 18:39:01 +09:00
|
|
|
|
border: "1px solid #e5e7eb",
|
|
|
|
|
|
borderRadius: "8px",
|
|
|
|
|
|
padding: "16px",
|
|
|
|
|
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
|
|
|
|
|
transition: "all 0.2s ease",
|
2025-09-15 17:10:46 +09:00
|
|
|
|
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) + "...";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-03 16:39:47 +09:00
|
|
|
|
// 컬럼 값을 문자열로 가져오기 (카테고리 타입인 경우 매핑된 라벨 반환)
|
|
|
|
|
|
const getColumnValueAsString = (data: any, columnName?: string): string => {
|
|
|
|
|
|
if (!columnName) return "";
|
|
|
|
|
|
const value = data[columnName];
|
|
|
|
|
|
if (value === null || value === undefined || value === "") return "";
|
|
|
|
|
|
|
|
|
|
|
|
// 카테고리 타입인 경우 매핑된 라벨 반환
|
|
|
|
|
|
const meta = columnMeta[columnName];
|
|
|
|
|
|
if (meta?.inputType === "category") {
|
|
|
|
|
|
const mapping = categoryMappings[columnName];
|
|
|
|
|
|
const valueStr = String(value);
|
|
|
|
|
|
const categoryData = mapping?.[valueStr];
|
|
|
|
|
|
return categoryData?.label || valueStr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return String(value);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 컬럼 매핑에서 값 가져오기 (카테고리 타입인 경우 배지로 표시)
|
|
|
|
|
|
const getColumnValue = (data: any, columnName?: string): React.ReactNode => {
|
2025-09-15 17:10:46 +09:00
|
|
|
|
if (!columnName) return "";
|
2025-12-03 16:39:47 +09:00
|
|
|
|
const value = data[columnName];
|
|
|
|
|
|
if (value === null || value === undefined || value === "") return "";
|
|
|
|
|
|
|
|
|
|
|
|
// 카테고리 타입인 경우 매핑된 라벨과 배지로 표시
|
|
|
|
|
|
const meta = columnMeta[columnName];
|
|
|
|
|
|
if (meta?.inputType === "category") {
|
|
|
|
|
|
const mapping = categoryMappings[columnName];
|
|
|
|
|
|
const valueStr = String(value);
|
|
|
|
|
|
const categoryData = mapping?.[valueStr];
|
|
|
|
|
|
const displayLabel = categoryData?.label || valueStr;
|
|
|
|
|
|
const displayColor = categoryData?.color;
|
|
|
|
|
|
|
|
|
|
|
|
// 색상이 없거나(null/undefined), 빈 문자열이거나, "none"이면 일반 텍스트로 표시 (배지 없음)
|
|
|
|
|
|
if (!displayColor || displayColor === "none") {
|
|
|
|
|
|
return displayLabel;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: displayColor,
|
|
|
|
|
|
borderColor: displayColor,
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-white text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
{displayLabel}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return String(value);
|
2025-09-15 17:10:46 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 컬럼명을 라벨로 변환하는 헬퍼 함수
|
|
|
|
|
|
const getColumnLabel = (columnName: string) => {
|
2025-12-03 16:02:09 +09:00
|
|
|
|
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());
|
2025-09-15 17:10:46 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
|
|
|
|
|
|
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?.();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-25 18:54:25 +09:00
|
|
|
|
// DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용)
|
|
|
|
|
|
const safeDomProps = filterDOMProps(props);
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<style jsx>{`
|
|
|
|
|
|
.card-hover {
|
2025-09-30 10:30:05 +09:00
|
|
|
|
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%;
|
2025-09-15 17:10:46 +09:00
|
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2025-09-30 10:30:05 +09:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2025-09-15 17:10:46 +09:00
|
|
|
|
`}</style>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={className}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
...componentStyle,
|
|
|
|
|
|
...style,
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={handleClick}
|
|
|
|
|
|
onDragStart={onDragStart}
|
|
|
|
|
|
onDragEnd={onDragEnd}
|
2025-09-25 18:54:25 +09:00
|
|
|
|
{...safeDomProps}
|
2025-09-15 17:10:46 +09:00
|
|
|
|
>
|
2025-09-30 10:30:05 +09:00
|
|
|
|
<div style={containerStyle} className="card-container">
|
2025-09-15 17:10:46 +09:00
|
|
|
|
{displayData.length === 0 ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
gridColumn: "1 / -1",
|
|
|
|
|
|
textAlign: "center",
|
|
|
|
|
|
padding: "40px 20px",
|
2025-10-30 15:39:39 +09:00
|
|
|
|
color: "hsl(var(--muted-foreground))",
|
2025-09-15 17:10:46 +09:00
|
|
|
|
fontSize: "14px",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
표시할 데이터가 없습니다.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
displayData.map((data, index) => {
|
2025-12-03 16:39:47 +09:00
|
|
|
|
// 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시)
|
2025-09-15 17:10:46 +09:00
|
|
|
|
const titleValue =
|
2025-12-03 16:39:47 +09:00
|
|
|
|
getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title");
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
|
|
|
|
|
const subtitleValue =
|
2025-12-03 16:39:47 +09:00
|
|
|
|
getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) ||
|
2025-09-15 17:10:46 +09:00
|
|
|
|
getAutoFallbackValue(data, "subtitle");
|
|
|
|
|
|
|
|
|
|
|
|
const descriptionValue =
|
2025-12-03 16:39:47 +09:00
|
|
|
|
getColumnValueAsString(data, componentConfig.columnMapping?.descriptionColumn) ||
|
2025-09-15 17:10:46 +09:00
|
|
|
|
getAutoFallbackValue(data, "description");
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 이미지 컬럼 자동 감지 (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) : "";
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
2025-12-02 18:03:52 +09:00
|
|
|
|
const cardKey = getCardKey(data, index);
|
|
|
|
|
|
const isCardSelected = selectedRows.has(cardKey);
|
2025-12-01 18:39:01 +09:00
|
|
|
|
|
2025-09-15 17:10:46 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
2025-12-02 18:03:52 +09:00
|
|
|
|
key={cardKey}
|
2025-12-01 18:39:01 +09:00
|
|
|
|
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)",
|
2025-12-03 16:02:09 +09:00
|
|
|
|
flexDirection: "row", // 가로 배치
|
2025-12-01 18:39:01 +09:00
|
|
|
|
}}
|
|
|
|
|
|
className="card-hover group cursor-pointer transition-all duration-150"
|
2025-12-02 18:03:52 +09:00
|
|
|
|
onClick={() => handleCardClick(data, index)}
|
2025-09-15 17:10:46 +09:00
|
|
|
|
>
|
2025-12-03 16:02:09 +09:00
|
|
|
|
{/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */}
|
|
|
|
|
|
{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>
|
2025-12-01 18:39:01 +09:00
|
|
|
|
)}
|
2025-09-15 17:10:46 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
{/* 우측 컨텐츠 영역 */}
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2025-09-15 17:10:46 +09:00
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
{/* 카드 설명 */}
|
|
|
|
|
|
{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>
|
2025-09-15 17:10:46 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-12-03 16:39:47 +09:00
|
|
|
|
{/* 카드 액션 - 설정에 따라 표시 */}
|
|
|
|
|
|
{(componentConfig.cardStyle?.showActions ?? true) && (
|
|
|
|
|
|
<div className="mt-2 flex justify-end space-x-2">
|
|
|
|
|
|
{(componentConfig.cardStyle?.showViewButton ?? true) && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="text-xs text-blue-600 hover:text-blue-800 transition-colors"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleCardView(data);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
상세보기
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{(componentConfig.cardStyle?.showEditButton ?? true) && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleCardEdit(data);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
편집
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2025-12-15 18:29:18 +09:00
|
|
|
|
{(componentConfig.cardStyle?.showDeleteButton ?? false) && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="text-xs text-red-500 hover:text-red-700 transition-colors"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleCardDelete(data, index);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
삭제
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2025-12-03 16:39:47 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-09-15 17:10:46 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-09-25 18:54:25 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 상세보기 모달 */}
|
|
|
|
|
|
<Dialog open={viewModalOpen} onOpenChange={setViewModalOpen}>
|
2025-12-05 10:46:10 +09:00
|
|
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
|
2025-09-25 18:54:25 +09:00
|
|
|
|
<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 !== '')
|
2025-12-03 16:39:47 +09:00
|
|
|
|
.map(([key, value]) => {
|
|
|
|
|
|
// 카테고리 타입인 경우 배지로 표시
|
|
|
|
|
|
const meta = columnMeta[key];
|
|
|
|
|
|
let displayValue: React.ReactNode = String(value);
|
|
|
|
|
|
|
|
|
|
|
|
if (meta?.inputType === "category") {
|
|
|
|
|
|
const mapping = categoryMappings[key];
|
|
|
|
|
|
const valueStr = String(value);
|
|
|
|
|
|
const categoryData = mapping?.[valueStr];
|
|
|
|
|
|
const displayLabel = categoryData?.label || valueStr;
|
|
|
|
|
|
const displayColor = categoryData?.color;
|
|
|
|
|
|
|
|
|
|
|
|
// 색상이 있고 "none"이 아닌 경우에만 배지로 표시
|
|
|
|
|
|
if (displayColor && displayColor !== "none") {
|
|
|
|
|
|
displayValue = (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: displayColor,
|
|
|
|
|
|
borderColor: displayColor,
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-white"
|
|
|
|
|
|
>
|
|
|
|
|
|
{displayLabel}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 배지 없음: 일반 텍스트로 표시
|
|
|
|
|
|
displayValue = displayLabel;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={key} className="bg-muted rounded-lg p-3">
|
|
|
|
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
|
|
|
|
|
{getColumnLabel(key)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm font-medium text-foreground break-words">
|
|
|
|
|
|
{displayValue}
|
|
|
|
|
|
</div>
|
2025-09-25 18:54:25 +09:00
|
|
|
|
</div>
|
2025-12-03 16:39:47 +09:00
|
|
|
|
);
|
|
|
|
|
|
})
|
2025-09-25 18:54:25 +09:00
|
|
|
|
}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end pt-4 border-t">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setViewModalOpen(false)}
|
2025-10-30 15:39:39 +09:00
|
|
|
|
className="px-4 py-2 text-sm font-medium text-foreground bg-muted hover:bg-muted/80 rounded-md transition-colors"
|
2025-09-25 18:54:25 +09:00
|
|
|
|
>
|
|
|
|
|
|
닫기
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 편집 모달 */}
|
|
|
|
|
|
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
|
2025-12-05 10:46:10 +09:00
|
|
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
|
2025-09-25 18:54:25 +09:00
|
|
|
|
<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">
|
2025-10-30 15:39:39 +09:00
|
|
|
|
<label className="text-sm font-medium text-foreground block">
|
2025-09-25 18:54:25 +09:00
|
|
|
|
{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}
|
2025-10-30 15:39:39 +09:00
|
|
|
|
className="bg-primary hover:bg-primary/90"
|
2025-09-25 18:54:25 +09:00
|
|
|
|
>
|
|
|
|
|
|
저장
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
2025-09-15 17:10:46 +09:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|