f04d224b09
- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
6869 lines
265 KiB
TypeScript
6869 lines
265 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
|
import { TableListConfig, ColumnConfig } from "./types";
|
|
import { WebType } from "@/types/common";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
|
import { codeCache } from "@/lib/caching/codeCache";
|
|
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
|
import { getFullImageUrl } from "@/lib/api/client";
|
|
import { getFilePreviewUrl } from "@/lib/api/file";
|
|
import { Button } from "@/components/ui/button";
|
|
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
|
|
|
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
|
|
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
|
|
// 다중 이미지(콤마 구분)인 경우 대표 이미지를 우선 표시
|
|
const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
|
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
|
|
const [displayObjid, setDisplayObjid] = React.useState<string>("");
|
|
const [error, setError] = React.useState(false);
|
|
const [loading, setLoading] = React.useState(true);
|
|
|
|
React.useEffect(() => {
|
|
let mounted = true;
|
|
const rawValue = String(value);
|
|
const parts = rawValue.split(",").map(s => s.trim()).filter(Boolean);
|
|
|
|
// 단일 값 또는 경로인 경우
|
|
if (parts.length <= 1) {
|
|
const strValue = parts[0] || rawValue;
|
|
setDisplayObjid(strValue);
|
|
const isObjid = /^\d+$/.test(strValue);
|
|
|
|
if (isObjid) {
|
|
loadImageBlob(strValue, mounted, setImgSrc, setError, setLoading);
|
|
} else {
|
|
setImgSrc(getFullImageUrl(strValue));
|
|
setLoading(false);
|
|
}
|
|
return () => { mounted = false; };
|
|
}
|
|
|
|
// 다중 objid: 대표 이미지를 찾아서 표시
|
|
const objids = parts.filter(s => /^\d+$/.test(s));
|
|
if (objids.length === 0) {
|
|
setLoading(false);
|
|
setError(true);
|
|
return () => { mounted = false; };
|
|
}
|
|
|
|
(async () => {
|
|
try {
|
|
const { getFileInfoByObjid } = await import("@/lib/api/file");
|
|
let representativeId: string | null = null;
|
|
|
|
// 각 objid의 대표 여부를 확인
|
|
for (const objid of objids) {
|
|
const info = await getFileInfoByObjid(objid);
|
|
if (info.success && info.data?.isRepresentative) {
|
|
representativeId = objid;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 대표 이미지가 없으면 첫 번째 사용
|
|
const targetObjid = representativeId || objids[0];
|
|
if (mounted) {
|
|
setDisplayObjid(targetObjid);
|
|
loadImageBlob(targetObjid, mounted, setImgSrc, setError, setLoading);
|
|
}
|
|
} catch {
|
|
if (mounted) {
|
|
// 대표 조회 실패 시 첫 번째 사용
|
|
setDisplayObjid(objids[0]);
|
|
loadImageBlob(objids[0], mounted, setImgSrc, setError, setLoading);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => { mounted = false; };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [value]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
|
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !imgSrc) {
|
|
return (
|
|
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
|
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
|
<img
|
|
src={imgSrc}
|
|
alt="이미지"
|
|
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
|
|
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const isObjid = /^\d+$/.test(displayObjid);
|
|
const openUrl = isObjid ? getFilePreviewUrl(displayObjid) : getFullImageUrl(displayObjid);
|
|
window.open(openUrl, "_blank");
|
|
}}
|
|
onError={() => setError(true)}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
TableCellImage.displayName = "TableCellImage";
|
|
|
|
// 이미지 blob 로딩 헬퍼
|
|
function loadImageBlob(
|
|
objid: string,
|
|
mounted: boolean,
|
|
setImgSrc: (url: string) => void,
|
|
setError: (err: boolean) => void,
|
|
setLoading: (loading: boolean) => void,
|
|
) {
|
|
import("@/lib/api/client").then(({ apiClient }) => {
|
|
apiClient.get(`/files/preview/${objid}`, { responseType: "blob" })
|
|
.then((response) => {
|
|
if (mounted) {
|
|
const blob = new Blob([response.data]);
|
|
setImgSrc(window.URL.createObjectURL(blob));
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (mounted) {
|
|
setError(true);
|
|
setLoading(false);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
|
|
declare global {
|
|
interface Window {
|
|
__relatedButtonsTargetTables?: Set<string>;
|
|
}
|
|
}
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronsLeft,
|
|
ChevronsRight,
|
|
RefreshCw,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
TableIcon,
|
|
Settings,
|
|
X,
|
|
Layers,
|
|
ChevronDown,
|
|
Filter,
|
|
Check,
|
|
Download,
|
|
FileSpreadsheet,
|
|
Copy,
|
|
ClipboardCopy,
|
|
Edit,
|
|
CheckSquare,
|
|
Trash2,
|
|
Lock,
|
|
} from "lucide-react";
|
|
import * as XLSX from "xlsx";
|
|
import { FileText, ChevronRightIcon } from "lucide-react";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
import { tableDisplayStore } from "@/stores/tableDisplayStore";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Label } from "@/components/ui/label";
|
|
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
|
|
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
|
import { CardModeRenderer } from "./CardModeRenderer";
|
|
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
|
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
|
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
|
|
|
// ========================================
|
|
// 인터페이스
|
|
// ========================================
|
|
|
|
// 그룹화된 데이터 인터페이스
|
|
interface GroupedData {
|
|
groupKey: string;
|
|
groupValues: Record<string, any>;
|
|
items: any[];
|
|
count: number;
|
|
summary?: Record<string, { sum: number; avg: number; count: number }>; // 🆕 그룹별 소계
|
|
}
|
|
|
|
// ========================================
|
|
// 캐시 및 유틸리티
|
|
// ========================================
|
|
|
|
const tableColumnCache = new Map<string, { columns: any[]; inputTypes?: any[]; timestamp: number }>();
|
|
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
|
|
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
|
|
|
const cleanupTableCache = () => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of tableColumnCache.entries()) {
|
|
if (now - entry.timestamp > TABLE_CACHE_TTL) {
|
|
tableColumnCache.delete(key);
|
|
}
|
|
}
|
|
for (const [key, entry] of tableInfoCache.entries()) {
|
|
if (now - entry.timestamp > TABLE_CACHE_TTL) {
|
|
tableInfoCache.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (typeof window !== "undefined") {
|
|
setInterval(cleanupTableCache, 10 * 60 * 1000);
|
|
}
|
|
|
|
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
|
const activeRequests = new Map<string, Promise<any>>();
|
|
|
|
const debouncedApiCall = <T extends any[], R>(key: string, fn: (...args: T) => Promise<R>, delay: number = 300) => {
|
|
return (...args: T): Promise<R> => {
|
|
const activeRequest = activeRequests.get(key);
|
|
if (activeRequest) {
|
|
return activeRequest as Promise<R>;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const existingTimer = debounceTimers.get(key);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
}
|
|
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const requestPromise = fn(...args);
|
|
activeRequests.set(key, requestPromise);
|
|
const result = await requestPromise;
|
|
resolve(result);
|
|
} catch (error) {
|
|
reject(error);
|
|
} finally {
|
|
debounceTimers.delete(key);
|
|
activeRequests.delete(key);
|
|
}
|
|
}, delay);
|
|
|
|
debounceTimers.set(key, timer);
|
|
});
|
|
};
|
|
};
|
|
|
|
// ========================================
|
|
// Filter Builder 인터페이스
|
|
// ========================================
|
|
|
|
interface FilterCondition {
|
|
id: string;
|
|
column: string;
|
|
operator:
|
|
| "equals"
|
|
| "notEquals"
|
|
| "contains"
|
|
| "notContains"
|
|
| "startsWith"
|
|
| "endsWith"
|
|
| "greaterThan"
|
|
| "lessThan"
|
|
| "greaterOrEqual"
|
|
| "lessOrEqual"
|
|
| "isEmpty"
|
|
| "isNotEmpty";
|
|
value: string;
|
|
}
|
|
|
|
interface FilterGroup {
|
|
id: string;
|
|
logic: "AND" | "OR";
|
|
conditions: FilterCondition[];
|
|
}
|
|
|
|
// ========================================
|
|
// Props 인터페이스
|
|
// ========================================
|
|
|
|
export interface TableListComponentProps {
|
|
component: any;
|
|
isDesignMode?: boolean;
|
|
isSelected?: boolean;
|
|
isInteractive?: boolean;
|
|
onClick?: () => void;
|
|
onDragStart?: (e: React.DragEvent) => void;
|
|
onDragEnd?: (e: React.DragEvent) => void;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
formData?: Record<string, any>;
|
|
onFormDataChange?: (data: any) => void;
|
|
config?: TableListConfig;
|
|
size?: { width: number; height: number };
|
|
position?: { x: number; y: number; z?: number };
|
|
componentConfig?: any;
|
|
selectedScreen?: any;
|
|
onZoneComponentDrop?: any;
|
|
onZoneClick?: any;
|
|
tableName?: string;
|
|
onRefresh?: () => void;
|
|
onClose?: () => void;
|
|
screenId?: number | string; // 화면 ID (필터 설정 저장용)
|
|
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
|
onSelectedRowsChange?: (
|
|
selectedRows: any[],
|
|
selectedRowsData: any[],
|
|
sortBy?: string,
|
|
sortOrder?: "asc" | "desc",
|
|
columnOrder?: string[],
|
|
tableDisplayData?: any[],
|
|
) => void;
|
|
onConfigChange?: (config: any) => void;
|
|
refreshKey?: number;
|
|
// 탭 관련 정보 (탭 내부의 테이블에서 사용)
|
|
parentTabId?: string; // 부모 탭 ID
|
|
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
|
// 🆕 프리뷰용 회사 코드 (DynamicComponentRenderer에서 전달, 최고 관리자만 오버라이드 가능)
|
|
companyCode?: string;
|
|
}
|
|
|
|
// ========================================
|
|
// 메인 컴포넌트
|
|
// ========================================
|
|
|
|
export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|
component,
|
|
isDesignMode = false,
|
|
isSelected = false,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
config,
|
|
className,
|
|
style,
|
|
formData: propFormData,
|
|
onFormDataChange,
|
|
componentConfig,
|
|
onSelectedRowsChange,
|
|
onConfigChange,
|
|
refreshKey,
|
|
tableName,
|
|
userId,
|
|
screenId,
|
|
parentTabId,
|
|
parentTabsComponentId,
|
|
companyCode,
|
|
}) => {
|
|
// ========================================
|
|
// 설정 및 스타일
|
|
// ========================================
|
|
|
|
const tableConfig = {
|
|
...config,
|
|
...component.config,
|
|
...componentConfig,
|
|
} as TableListConfig;
|
|
|
|
// selectedTable 안전하게 추출 (문자열인지 확인)
|
|
let finalSelectedTable =
|
|
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
|
|
|
|
// 디버그 로그 제거 (성능 최적화)
|
|
|
|
// 객체인 경우 tableName 속성 추출 시도
|
|
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
|
|
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
|
|
}
|
|
|
|
tableConfig.selectedTable = finalSelectedTable;
|
|
|
|
// 디버그 로그 제거 (성능 최적화)
|
|
|
|
const buttonColor = component.style?.labelColor || "#212121";
|
|
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
|
|
|
const gridColumns = component.gridColumns || 1;
|
|
let calculatedWidth: string;
|
|
|
|
if (isDesignMode) {
|
|
if (gridColumns === 1) {
|
|
calculatedWidth = "400px";
|
|
} else if (gridColumns === 2) {
|
|
calculatedWidth = "800px";
|
|
} else {
|
|
calculatedWidth = "100%";
|
|
}
|
|
} else {
|
|
calculatedWidth = "100%";
|
|
}
|
|
|
|
const componentStyle: React.CSSProperties = {
|
|
position: "relative",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
backgroundColor: "hsl(var(--background))",
|
|
overflow: "hidden",
|
|
boxSizing: "border-box",
|
|
width: "100%",
|
|
height: "100%",
|
|
minHeight: isDesignMode ? "300px" : "100%",
|
|
...style, // style prop이 위의 기본값들을 덮어씀
|
|
};
|
|
|
|
// ========================================
|
|
// 상태 관리
|
|
// ========================================
|
|
|
|
// 사용자 정보 (props에서 받거나 useAuth에서 가져오기)
|
|
const { userId: authUserId } = useAuth();
|
|
const currentUserId = userId || authUserId;
|
|
|
|
// 화면 컨텍스트 (데이터 제공자로 등록)
|
|
const screenContext = useScreenContextOptional();
|
|
|
|
// 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록)
|
|
const splitPanelContext = useSplitPanelContext();
|
|
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
|
|
|
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
|
|
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
|
|
|
|
// 🆕 RelatedDataButtons 컴포넌트에서 발생하는 필터 상태
|
|
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
|
|
filterColumn: string;
|
|
filterValue: any;
|
|
} | null>(null);
|
|
|
|
// 🆕 RelatedDataButtons가 이 테이블을 대상으로 등록되어 있는지 여부
|
|
const [isRelatedButtonTarget, setIsRelatedButtonTarget] = useState(() => {
|
|
// 초기값: 전역 레지스트리에서 확인
|
|
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables) {
|
|
return window.__relatedButtonsTargetTables.has(tableConfig.selectedTable || "");
|
|
}
|
|
return false;
|
|
});
|
|
|
|
// TableOptions Context
|
|
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
|
const [grouping, setGrouping] = useState<string[]>([]);
|
|
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
|
|
|
|
// filters가 변경되면 searchValues 업데이트 (실시간 검색)
|
|
useEffect(() => {
|
|
const newSearchValues: Record<string, any> = {};
|
|
filters.forEach((filter) => {
|
|
if (filter.value) {
|
|
// operator 정보도 함께 전달 (백엔드에서 equals/contains 구분)
|
|
newSearchValues[filter.columnName] = {
|
|
value: filter.value,
|
|
operator: filter.operator || "contains",
|
|
};
|
|
}
|
|
});
|
|
|
|
// filters → searchValues 변환 완료
|
|
|
|
setSearchValues(newSearchValues);
|
|
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
|
}, [filters]);
|
|
|
|
// grouping이 변경되면 groupByColumns 업데이트
|
|
useEffect(() => {
|
|
setGroupByColumns(grouping);
|
|
}, [grouping]);
|
|
|
|
// 초기 로드 시 localStorage에서 저장된 설정 불러오기
|
|
useEffect(() => {
|
|
if (tableConfig.selectedTable && currentUserId) {
|
|
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
|
const savedSettings = localStorage.getItem(storageKey);
|
|
|
|
if (savedSettings) {
|
|
try {
|
|
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
|
|
setColumnVisibility(parsed);
|
|
} catch (error) {
|
|
console.error("저장된 컬럼 설정 불러오기 실패:", error);
|
|
}
|
|
}
|
|
}
|
|
}, [tableConfig.selectedTable, currentUserId]);
|
|
|
|
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
|
|
useEffect(() => {
|
|
if (columnVisibility.length > 0) {
|
|
const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외
|
|
setColumnOrder(newOrder);
|
|
|
|
// localStorage에 저장 (사용자별)
|
|
if (tableConfig.selectedTable && currentUserId) {
|
|
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
|
localStorage.setItem(storageKey, JSON.stringify(columnVisibility));
|
|
}
|
|
}
|
|
}, [columnVisibility, tableConfig.selectedTable, currentUserId]);
|
|
|
|
// 🆕 columnOrder를 visibleColumns 이전에 정의 (visibleColumns에서 사용)
|
|
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
|
|
// 🆕 visibleColumns를 상단에서 정의 (다른 useCallback/useMemo에서 사용하기 위해)
|
|
const visibleColumns = useMemo(() => {
|
|
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
|
|
|
// columnVisibility가 있으면 가시성 적용
|
|
if (columnVisibility.length > 0) {
|
|
cols = cols.filter((col) => {
|
|
const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName);
|
|
return visibilityConfig ? visibilityConfig.visible : true;
|
|
});
|
|
}
|
|
|
|
// 체크박스 컬럼 (나중에 위치 결정)
|
|
// 기본값: enabled가 undefined면 true로 처리
|
|
let checkboxCol: ColumnConfig | null = null;
|
|
if (tableConfig.checkbox?.enabled ?? true) {
|
|
checkboxCol = {
|
|
columnName: "__checkbox__",
|
|
displayName: "",
|
|
visible: true,
|
|
sortable: false,
|
|
searchable: false,
|
|
width: 40,
|
|
align: "center" as const,
|
|
order: -1,
|
|
editable: false, // 체크박스는 편집 불가
|
|
};
|
|
}
|
|
|
|
// columnOrder가 있으면 해당 순서로 정렬
|
|
if (columnOrder.length > 0) {
|
|
const orderMap = new Map(columnOrder.map((name, idx) => [name, idx]));
|
|
cols = [...cols].sort((a, b) => {
|
|
const aIdx = orderMap.get(a.columnName) ?? 9999;
|
|
const bIdx = orderMap.get(b.columnName) ?? 9999;
|
|
return aIdx - bIdx;
|
|
});
|
|
}
|
|
|
|
// 체크박스 위치 결정
|
|
if (checkboxCol) {
|
|
const checkboxPosition = tableConfig.checkbox?.position || "left";
|
|
if (checkboxPosition === "left") {
|
|
return [checkboxCol, ...cols];
|
|
} else {
|
|
return [...cols, checkboxCol];
|
|
}
|
|
}
|
|
|
|
return cols;
|
|
}, [tableConfig.columns, tableConfig.checkbox, columnVisibility, columnOrder]);
|
|
|
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 🆕 컬럼 헤더 필터 상태 (상단에서 선언)
|
|
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
|
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
|
|
|
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
|
|
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
|
|
|
// 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함
|
|
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
|
|
|
|
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
|
|
const filteredData = useMemo(() => {
|
|
let result = data;
|
|
|
|
// 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
|
|
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
|
|
const addedIds = splitPanelContext.addedItemIds;
|
|
result = result.filter((row) => {
|
|
const rowId = String(row.id || row.po_item_id || row.item_id || "");
|
|
return !addedIds.has(rowId);
|
|
});
|
|
}
|
|
|
|
// 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함)
|
|
if (Object.keys(headerFilters).length > 0) {
|
|
result = result.filter((row) => {
|
|
return Object.entries(headerFilters).every(([columnName, values]) => {
|
|
if (values.size === 0) return true;
|
|
|
|
// joinColumnMapping을 사용하여 조인된 컬럼명 확인
|
|
const mappedColumnName = joinColumnMapping[columnName] || columnName;
|
|
|
|
// 여러 가능한 컬럼명 시도 (mappedColumnName 우선)
|
|
const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
|
|
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
|
|
|
|
return values.has(cellStr);
|
|
});
|
|
});
|
|
}
|
|
|
|
// 3. 🆕 Filter Builder 적용
|
|
if (filterGroups.length > 0) {
|
|
result = result.filter((row) => {
|
|
return filterGroups.every((group) => {
|
|
const validConditions = group.conditions.filter(
|
|
(c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value),
|
|
);
|
|
if (validConditions.length === 0) return true;
|
|
|
|
const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => {
|
|
const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : "";
|
|
const condValue = condition.value.toLowerCase();
|
|
|
|
switch (condition.operator) {
|
|
case "equals":
|
|
return strValue === condValue;
|
|
case "notEquals":
|
|
return strValue !== condValue;
|
|
case "contains":
|
|
return strValue.includes(condValue);
|
|
case "notContains":
|
|
return !strValue.includes(condValue);
|
|
case "startsWith":
|
|
return strValue.startsWith(condValue);
|
|
case "endsWith":
|
|
return strValue.endsWith(condValue);
|
|
case "greaterThan":
|
|
return parseFloat(strValue) > parseFloat(condValue);
|
|
case "lessThan":
|
|
return parseFloat(strValue) < parseFloat(condValue);
|
|
case "greaterOrEqual":
|
|
return parseFloat(strValue) >= parseFloat(condValue);
|
|
case "lessOrEqual":
|
|
return parseFloat(strValue) <= parseFloat(condValue);
|
|
case "isEmpty":
|
|
return strValue === "" || value === null || value === undefined;
|
|
case "isNotEmpty":
|
|
return strValue !== "" && value !== null && value !== undefined;
|
|
default:
|
|
return true;
|
|
}
|
|
};
|
|
|
|
if (group.logic === "AND") {
|
|
return validConditions.every((cond) => evaluateCondition(row[cond.column], cond));
|
|
} else {
|
|
return validConditions.some((cond) => evaluateCondition(row[cond.column], cond));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [totalItems, setTotalItems] = useState(0);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
|
const hasInitializedSort = useRef(false);
|
|
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
|
const [tableLabel, setTableLabel] = useState<string>("");
|
|
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
|
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
|
const [columnMeta, setColumnMeta] = useState<
|
|
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }>
|
|
>({});
|
|
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
|
|
const [joinedColumnMeta, setJoinedColumnMeta] = useState<
|
|
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
|
>({});
|
|
const [categoryMappings, setCategoryMappings] = useState<
|
|
Record<string, Record<string, { label: string; color?: string }>>
|
|
>({});
|
|
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
|
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
// columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요)
|
|
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
|
const [isAllSelected, setIsAllSelected] = useState(false);
|
|
const hasInitializedWidths = useRef(false);
|
|
const isResizing = useRef(false);
|
|
|
|
// 필터 설정 관련 상태
|
|
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
|
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
|
|
|
// 🆕 키보드 네비게이션 관련 상태
|
|
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 🆕 인라인 셀 편집 관련 상태
|
|
const [editingCell, setEditingCell] = useState<{
|
|
rowIndex: number;
|
|
colIndex: number;
|
|
columnName: string;
|
|
originalValue: any;
|
|
} | null>(null);
|
|
const [editingValue, setEditingValue] = useState<string>("");
|
|
const editInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 🆕 배치 편집 관련 상태
|
|
const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드
|
|
const [pendingChanges, setPendingChanges] = useState<
|
|
Map<
|
|
string,
|
|
{
|
|
rowIndex: number;
|
|
columnName: string;
|
|
originalValue: any;
|
|
newValue: any;
|
|
primaryKeyValue: any;
|
|
}
|
|
>
|
|
>(new Map()); // key: `${rowIndex}-${columnName}`
|
|
const [localEditedData, setLocalEditedData] = useState<Record<number, Record<string, any>>>({}); // 로컬 수정 데이터
|
|
|
|
// 🆕 유효성 검사 관련 상태
|
|
const [validationErrors, setValidationErrors] = useState<Map<string, string>>(new Map()); // key: `${rowIndex}-${columnName}`
|
|
|
|
// 🆕 유효성 검사 규칙 타입
|
|
type ValidationRule = {
|
|
required?: boolean;
|
|
min?: number;
|
|
max?: number;
|
|
minLength?: number;
|
|
maxLength?: number;
|
|
pattern?: RegExp;
|
|
customMessage?: string;
|
|
validate?: (value: any, row: any) => string | null; // 커스텀 검증 함수 (에러 메시지 또는 null)
|
|
};
|
|
|
|
// 🆕 Cascading Lookups 관련 상태
|
|
const [cascadingOptions, setCascadingOptions] = useState<Record<string, { value: string; label: string }[]>>({});
|
|
const [loadingCascading, setLoadingCascading] = useState<Record<string, boolean>>({});
|
|
|
|
// 🆕 Multi-Level Headers (Column Bands) 타입
|
|
type ColumnBand = {
|
|
caption: string;
|
|
columns: string[]; // 포함될 컬럼명 배열
|
|
};
|
|
|
|
// 그룹 설정 관련 상태
|
|
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
|
|
|
// 🆕 그룹별 합산 설정 상태
|
|
const [groupSumConfig, setGroupSumConfig] = useState<GroupSumConfig | null>(null);
|
|
|
|
// 🆕 Master-Detail 관련 상태
|
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()); // 확장된 행 키 목록
|
|
const [detailData, setDetailData] = useState<Record<string, any[]>>({}); // 상세 데이터 캐시
|
|
|
|
// 🆕 Drag & Drop 재정렬 관련 상태
|
|
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
|
|
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
|
const [isDragEnabled, setIsDragEnabled] = useState<boolean>((tableConfig as any).enableRowDrag ?? false);
|
|
|
|
// 🆕 Virtual Scrolling 관련 상태
|
|
const [isVirtualScrollEnabled] = useState<boolean>((tableConfig as any).virtualScroll ?? false);
|
|
const [scrollTop, setScrollTop] = useState(0);
|
|
const ROW_HEIGHT = 40; // 각 행의 높이 (픽셀)
|
|
const OVERSCAN = 5; // 버퍼로 추가 렌더링할 행 수
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 🆕 Column Reordering 관련 상태
|
|
const [draggedColumnIndex, setDraggedColumnIndex] = useState<number | null>(null);
|
|
const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState<number | null>(null);
|
|
const [isColumnDragEnabled] = useState<boolean>((tableConfig as any).enableColumnDrag ?? true);
|
|
|
|
// 🆕 State Persistence: 통합 상태 키
|
|
const tableStateKey = useMemo(() => {
|
|
if (!tableConfig.selectedTable) return null;
|
|
return `tableState_${tableConfig.selectedTable}`;
|
|
}, [tableConfig.selectedTable]);
|
|
|
|
// 🆕 Real-Time Updates 관련 상태
|
|
const [isRealTimeEnabled] = useState<boolean>((tableConfig as any).realTimeUpdates ?? false);
|
|
const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">(
|
|
"disconnected",
|
|
);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// 🆕 Context Menu 관련 상태
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
x: number;
|
|
y: number;
|
|
rowIndex: number;
|
|
colIndex: number;
|
|
row: any;
|
|
} | null>(null);
|
|
|
|
// 사용자 옵션 모달 관련 상태
|
|
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
|
|
const [showGridLines, setShowGridLines] = useState(true);
|
|
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
|
// 체크박스 컬럼은 항상 기본 틀고정
|
|
const [frozenColumns, setFrozenColumns] = useState<string[]>(
|
|
(tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [],
|
|
);
|
|
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
|
|
|
// 🆕 Search Panel (통합 검색) 관련 상태
|
|
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
|
|
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
|
const [searchHighlights, setSearchHighlights] = useState<Set<string>>(new Set()); // "rowIndex-colIndex" 형식
|
|
|
|
// 🆕 Filter Builder (고급 필터) 관련 상태 추가
|
|
const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false);
|
|
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
|
|
|
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
|
|
useEffect(() => {
|
|
const linkedFilters = tableConfig.linkedFilters;
|
|
|
|
if (!linkedFilters || linkedFilters.length === 0 || !screenContext) {
|
|
return;
|
|
}
|
|
|
|
// 연결된 소스 컴포넌트들의 값을 주기적으로 확인
|
|
const checkLinkedFilters = () => {
|
|
const newFilterValues: Record<string, any> = {};
|
|
let hasChanges = false;
|
|
|
|
linkedFilters.forEach((filter) => {
|
|
if (filter.enabled === false) return;
|
|
|
|
const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId);
|
|
if (sourceProvider) {
|
|
const selectedData = sourceProvider.getSelectedData();
|
|
if (selectedData && selectedData.length > 0) {
|
|
const sourceField = filter.sourceField || "value";
|
|
const value = selectedData[0][sourceField];
|
|
|
|
if (value !== linkedFilterValues[filter.targetColumn]) {
|
|
newFilterValues[filter.targetColumn] = value;
|
|
hasChanges = true;
|
|
} else {
|
|
newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn];
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (hasChanges) {
|
|
setLinkedFilterValues(newFilterValues);
|
|
|
|
// searchValues에 연결된 필터 값 병합
|
|
setSearchValues((prev) => ({
|
|
...prev,
|
|
...newFilterValues,
|
|
}));
|
|
|
|
// 첫 페이지로 이동
|
|
setCurrentPage(1);
|
|
}
|
|
};
|
|
|
|
// 초기 체크
|
|
checkLinkedFilters();
|
|
|
|
// 주기적으로 체크 (500ms마다)
|
|
const intervalId = setInterval(checkLinkedFilters, 500);
|
|
|
|
return () => {
|
|
clearInterval(intervalId);
|
|
};
|
|
}, [screenContext, tableConfig.linkedFilters, linkedFilterValues]);
|
|
|
|
// DataProvidable 인터페이스 구현
|
|
const dataProvider: DataProvidable = {
|
|
componentId: component.id,
|
|
componentType: "table-list",
|
|
tableName: tableConfig.selectedTable,
|
|
|
|
getSelectedData: () => {
|
|
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
|
const selectedData = filteredData.filter((row) => {
|
|
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
|
|
return selectedRows.has(rowId);
|
|
});
|
|
return selectedData;
|
|
},
|
|
|
|
getAllData: () => {
|
|
// 🆕 필터링된 데이터 반환
|
|
return filteredData;
|
|
},
|
|
|
|
clearSelection: () => {
|
|
setSelectedRows(new Set());
|
|
setIsAllSelected(false);
|
|
},
|
|
};
|
|
|
|
// DataReceivable 인터페이스 구현
|
|
const dataReceiver: DataReceivable = {
|
|
componentId: component.id,
|
|
componentType: "table",
|
|
|
|
receiveData: async (receivedData: any[], config: DataReceiverConfig) => {
|
|
try {
|
|
let newData: any[] = [];
|
|
|
|
switch (config.mode) {
|
|
case "append":
|
|
// 기존 데이터에 추가
|
|
newData = [...data, ...receivedData];
|
|
break;
|
|
|
|
case "replace":
|
|
// 기존 데이터를 완전히 교체
|
|
newData = receivedData;
|
|
break;
|
|
|
|
case "merge":
|
|
// 기존 데이터와 병합 (ID 기반)
|
|
const existingMap = new Map(data.map((item) => [item.id, item]));
|
|
receivedData.forEach((item) => {
|
|
if (item.id && existingMap.has(item.id)) {
|
|
// 기존 데이터 업데이트
|
|
existingMap.set(item.id, { ...existingMap.get(item.id), ...item });
|
|
} else {
|
|
// 새 데이터 추가
|
|
existingMap.set(item.id || Date.now() + Math.random(), item);
|
|
}
|
|
});
|
|
newData = Array.from(existingMap.values());
|
|
break;
|
|
}
|
|
|
|
// 상태 업데이트
|
|
setData(newData);
|
|
|
|
// 총 아이템 수 업데이트
|
|
setTotalItems(newData.length);
|
|
} catch (error) {
|
|
console.error("데이터 수신 실패:", error);
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getData: () => {
|
|
return data;
|
|
},
|
|
};
|
|
|
|
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
|
|
useEffect(() => {
|
|
if (screenContext && component.id) {
|
|
screenContext.registerDataProvider(component.id, dataProvider);
|
|
screenContext.registerDataReceiver(component.id, dataReceiver);
|
|
|
|
return () => {
|
|
screenContext.unregisterDataProvider(component.id);
|
|
screenContext.unregisterDataReceiver(component.id);
|
|
};
|
|
}
|
|
}, [screenContext, component.id, data, selectedRows]);
|
|
|
|
// 분할 패널 컨텍스트에 데이터 수신자로 등록
|
|
// useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
|
const currentSplitPosition =
|
|
splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null;
|
|
|
|
useEffect(() => {
|
|
if (splitPanelContext && component.id && currentSplitPosition) {
|
|
const splitPanelReceiver = {
|
|
componentId: component.id,
|
|
componentType: "table-list",
|
|
receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => {
|
|
await dataReceiver.receiveData(incomingData, {
|
|
targetComponentId: component.id,
|
|
targetComponentType: "table-list",
|
|
mode,
|
|
mappingRules: [],
|
|
});
|
|
},
|
|
};
|
|
|
|
splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver);
|
|
|
|
return () => {
|
|
splitPanelContext.unregisterReceiver(currentSplitPosition, component.id);
|
|
};
|
|
}
|
|
}, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]);
|
|
|
|
// 테이블 등록 (Context에 등록)
|
|
const tableId = `table-list-${component.id}`;
|
|
|
|
useEffect(() => {
|
|
// tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음)
|
|
const columnsToRegister = (tableConfig.columns || []).filter(
|
|
(col) => col.visible !== false && col.columnName !== "__checkbox__",
|
|
);
|
|
|
|
if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// 컬럼의 고유 값 조회 함수
|
|
const getColumnUniqueValues = async (columnName: string) => {
|
|
const meta = columnMeta[columnName];
|
|
const inputType = meta?.inputType || "text";
|
|
|
|
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API)
|
|
if (inputType === "category") {
|
|
try {
|
|
// API 클라이언트 사용 (쿠키 인증 자동 처리)
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
|
|
|
|
if (response.data.success && response.data.data) {
|
|
const categoryOptions = response.data.data.map((item: any) => ({
|
|
value: item.valueCode, // 카멜케이스
|
|
label: item.valueLabel, // 카멜케이스
|
|
}));
|
|
|
|
return categoryOptions;
|
|
}
|
|
} catch (error: any) {
|
|
// 에러 시 현재 데이터 기반으로 fallback
|
|
}
|
|
}
|
|
|
|
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
|
|
|
|
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
|
return response.data.data.map((item: any) => ({
|
|
value: String(item.value),
|
|
label: String(item.label),
|
|
}));
|
|
}
|
|
} catch (error: any) {
|
|
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
|
|
}
|
|
|
|
// fallback: 현재 로드된 데이터에서 고유 값 추출
|
|
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
|
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
|
|
|
const uniqueValuesMap = new Map<string, string>();
|
|
|
|
data.forEach((row) => {
|
|
const value = row[columnName];
|
|
if (value !== null && value !== undefined && value !== "") {
|
|
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
|
uniqueValuesMap.set(String(value), label);
|
|
}
|
|
});
|
|
|
|
const result = Array.from(uniqueValuesMap.entries())
|
|
.map(([value, label]) => ({
|
|
value: value,
|
|
label: label,
|
|
}))
|
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
|
|
return result;
|
|
};
|
|
|
|
const registration = {
|
|
tableId,
|
|
label: tableLabel || tableConfig.selectedTable,
|
|
tableName: tableConfig.selectedTable,
|
|
dataCount: totalItems || data.length, // 초기 데이터 건수 포함
|
|
columns: columnsToRegister.map((col) => ({
|
|
columnName: col.columnName || col.field,
|
|
columnLabel: columnLabels[col.columnName] || col.displayName || col.label || col.columnName || col.field,
|
|
inputType: columnMeta[col.columnName]?.inputType || "text",
|
|
visible: col.visible !== false,
|
|
width: columnWidths[col.columnName] || col.width || 150,
|
|
sortable: col.sortable !== false,
|
|
filterable: col.searchable !== false,
|
|
})),
|
|
onFilterChange: setFilters,
|
|
onGroupChange: setGrouping,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
getColumnUniqueValues, // 고유 값 조회 함수 등록
|
|
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
|
|
// 틀고정 컬럼 관련
|
|
frozenColumnCount, // 현재 틀고정 컬럼 수
|
|
onFrozenColumnCountChange: (count: number) => {
|
|
setFrozenColumnCount(count);
|
|
// 체크박스 컬럼은 항상 틀고정에 포함
|
|
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
|
|
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
|
|
const visibleCols = columnsToRegister
|
|
.filter((col) => col.visible !== false)
|
|
.map((col) => col.columnName || col.field);
|
|
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
|
|
setFrozenColumns(newFrozenColumns);
|
|
},
|
|
// 탭 관련 정보 (탭 내부의 테이블인 경우)
|
|
parentTabId,
|
|
parentTabsComponentId,
|
|
screenId: screenId ? Number(screenId) : undefined,
|
|
};
|
|
|
|
registerTable(registration);
|
|
|
|
return () => {
|
|
unregisterTable(tableId);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
tableId,
|
|
tableConfig.selectedTable,
|
|
tableConfig.columns,
|
|
columnLabels,
|
|
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
|
|
columnWidths,
|
|
tableLabel,
|
|
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
|
|
totalItems, // 전체 항목 수가 변경되면 재등록
|
|
registerTable,
|
|
// unregisterTable은 의존성에서 제외 - 무한 루프 방지
|
|
// unregisterTable 함수는 의존성이 없어 안정적임
|
|
]);
|
|
|
|
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용)
|
|
useEffect(() => {
|
|
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
|
|
|
|
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
|
|
const savedSort = localStorage.getItem(storageKey);
|
|
|
|
if (savedSort) {
|
|
try {
|
|
const { column, direction } = JSON.parse(savedSort);
|
|
if (column && direction) {
|
|
setSortColumn(column);
|
|
setSortDirection(direction);
|
|
hasInitializedSort.current = true;
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
// 정렬 상태 복원 실패
|
|
}
|
|
}
|
|
|
|
// localStorage에 저장된 정렬이 없으면 defaultSort 설정 적용
|
|
if (tableConfig.defaultSort?.columnName) {
|
|
setSortColumn(tableConfig.defaultSort.columnName);
|
|
setSortDirection(tableConfig.defaultSort.direction || "asc");
|
|
hasInitializedSort.current = true;
|
|
}
|
|
}, [tableConfig.selectedTable, tableConfig.defaultSort, userId]);
|
|
|
|
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
|
|
useEffect(() => {
|
|
if (!tableConfig.selectedTable || !userId) return;
|
|
|
|
const userKey = userId || "guest";
|
|
const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`;
|
|
const savedOrder = localStorage.getItem(storageKey);
|
|
|
|
if (savedOrder) {
|
|
try {
|
|
const parsedOrder = JSON.parse(savedOrder);
|
|
setColumnOrder(parsedOrder);
|
|
|
|
// 부모 컴포넌트에 초기 컬럼 순서 전달
|
|
if (onSelectedRowsChange && parsedOrder.length > 0) {
|
|
|
|
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
|
|
const initialData = data.map((row: any) => {
|
|
const reordered: any = {};
|
|
parsedOrder.forEach((colName: string) => {
|
|
if (colName in row) {
|
|
reordered[colName] = row[colName];
|
|
}
|
|
});
|
|
// 나머지 컬럼 추가
|
|
Object.keys(row).forEach((key) => {
|
|
if (!(key in reordered)) {
|
|
reordered[key] = row[key];
|
|
}
|
|
});
|
|
return reordered;
|
|
});
|
|
|
|
// 전역 저장소에 데이터 저장
|
|
if (tableConfig.selectedTable) {
|
|
// 컬럼 라벨 매핑 생성 (tableConfig.columns 사용 - visibleColumns는 아직 정의되지 않음)
|
|
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
|
const labels: Record<string, string> = {};
|
|
cols.forEach((col) => {
|
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
|
});
|
|
|
|
tableDisplayStore.setTableData(
|
|
tableConfig.selectedTable,
|
|
initialData,
|
|
parsedOrder.filter((col) => col !== "__checkbox__"),
|
|
sortColumn,
|
|
sortDirection,
|
|
{
|
|
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
|
|
searchTerm: searchTerm || undefined,
|
|
visibleColumns: cols.map((col) => col.columnName),
|
|
columnLabels: labels,
|
|
currentPage: currentPage,
|
|
pageSize: localPageSize,
|
|
totalItems: totalItems,
|
|
},
|
|
);
|
|
}
|
|
|
|
onSelectedRowsChange([], [], sortColumn, sortDirection, parsedOrder, initialData);
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ 컬럼 순서 파싱 실패:", error);
|
|
}
|
|
}
|
|
}, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행)
|
|
|
|
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
|
|
enableBatchLoading: true,
|
|
preloadCommonCodes: true,
|
|
maxBatchSize: 5,
|
|
});
|
|
|
|
// ========================================
|
|
// 컬럼 라벨 가져오기
|
|
// ========================================
|
|
|
|
const fetchColumnLabels = useCallback(async () => {
|
|
if (!tableConfig.selectedTable) return;
|
|
|
|
try {
|
|
// 🔥 FIX: 캐시 키에 회사 코드 포함 (멀티테넌시 지원)
|
|
const currentUser = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
|
const companyCode = currentUser.companyCode || "UNKNOWN";
|
|
const cacheKey = `columns_${tableConfig.selectedTable}_${companyCode}`;
|
|
const cached = tableColumnCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
|
|
const labels: Record<string, string> = {};
|
|
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
|
|
|
|
const inputTypeMap: Record<string, string> = {};
|
|
const categoryRefMap: Record<string, string> = {};
|
|
if (cached.inputTypes) {
|
|
cached.inputTypes.forEach((col: any) => {
|
|
inputTypeMap[col.columnName] = col.inputType;
|
|
if (col.categoryRef) {
|
|
categoryRefMap[col.columnName] = col.categoryRef;
|
|
}
|
|
});
|
|
}
|
|
|
|
cached.columns.forEach((col: any) => {
|
|
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
|
meta[col.columnName] = {
|
|
webType: col.webType,
|
|
codeCategory: col.codeCategory,
|
|
inputType: inputTypeMap[col.columnName],
|
|
categoryRef: categoryRefMap[col.columnName],
|
|
};
|
|
});
|
|
|
|
setColumnLabels(labels);
|
|
setColumnMeta(meta);
|
|
return;
|
|
}
|
|
|
|
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
|
|
|
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
|
|
const inputTypeMap: Record<string, string> = {};
|
|
const categoryRefMap: Record<string, string> = {};
|
|
inputTypes.forEach((col: any) => {
|
|
inputTypeMap[col.columnName] = col.inputType;
|
|
if (col.categoryRef) {
|
|
categoryRefMap[col.columnName] = col.categoryRef;
|
|
}
|
|
});
|
|
|
|
tableColumnCache.set(cacheKey, {
|
|
columns,
|
|
inputTypes,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
const labels: Record<string, string> = {};
|
|
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
|
|
|
|
columns.forEach((col: any) => {
|
|
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
|
meta[col.columnName] = {
|
|
webType: col.webType,
|
|
codeCategory: col.codeCategory,
|
|
inputType: inputTypeMap[col.columnName],
|
|
categoryRef: categoryRefMap[col.columnName],
|
|
};
|
|
});
|
|
|
|
setColumnLabels(labels);
|
|
setColumnMeta(meta);
|
|
} catch (error) {
|
|
console.error("컬럼 라벨 가져오기 실패:", error);
|
|
}
|
|
}, [tableConfig.selectedTable]);
|
|
|
|
// ========================================
|
|
// 테이블 라벨 가져오기
|
|
// ========================================
|
|
|
|
const fetchTableLabel = useCallback(async () => {
|
|
if (!tableConfig.selectedTable) return;
|
|
|
|
try {
|
|
const cacheKey = `table_info_${tableConfig.selectedTable}`;
|
|
const cached = tableInfoCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
|
|
const tables = cached.tables || [];
|
|
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
|
|
const label =
|
|
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
|
|
setTableLabel(label);
|
|
return;
|
|
}
|
|
|
|
const tables = await tableTypeApi.getTables();
|
|
|
|
tableInfoCache.set(cacheKey, {
|
|
tables,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
|
|
const label =
|
|
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
|
|
setTableLabel(label);
|
|
} catch (error) {
|
|
console.error("테이블 라벨 가져오기 실패:", error);
|
|
}
|
|
}, [tableConfig.selectedTable]);
|
|
|
|
// ========================================
|
|
// 카테고리 값 매핑 로드
|
|
// ========================================
|
|
|
|
// 카테고리 컬럼 목록 추출 (useMemo로 최적화)
|
|
const categoryColumns = useMemo(() => {
|
|
return Object.entries(columnMeta)
|
|
.filter(([_, meta]) => meta.inputType === "category")
|
|
.map(([columnName, _]) => columnName);
|
|
}, [columnMeta]);
|
|
|
|
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
|
|
useEffect(() => {
|
|
const loadCategoryMappings = async () => {
|
|
if (!tableConfig.selectedTable) {
|
|
return;
|
|
}
|
|
|
|
if (categoryColumns.length === 0) {
|
|
setCategoryMappings({});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
|
const apiClient = (await import("@/lib/api/client")).apiClient;
|
|
|
|
for (const columnName of categoryColumns) {
|
|
try {
|
|
let targetTable = tableConfig.selectedTable;
|
|
let targetColumn = columnName;
|
|
|
|
// category_ref가 있으면 참조 테이블.컬럼 기준으로 조회
|
|
const meta = columnMeta[columnName];
|
|
if (meta?.categoryRef) {
|
|
const refParts = meta.categoryRef.split(".");
|
|
if (refParts.length === 2) {
|
|
targetTable = refParts[0];
|
|
targetColumn = refParts[1];
|
|
}
|
|
} else if (columnName.includes(".")) {
|
|
// 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태
|
|
const parts = columnName.split(".");
|
|
targetTable = parts[0];
|
|
targetColumn = parts[1];
|
|
}
|
|
|
|
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
|
|
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
|
|
|
// 트리 구조를 평탄화하는 헬퍼 함수
|
|
const flattenTree = (items: any[]) => {
|
|
items.forEach((item: any) => {
|
|
// valueCode를 문자열로 변환하여 키로 사용
|
|
if (item.valueCode) {
|
|
const key = String(item.valueCode);
|
|
mapping[key] = {
|
|
label: item.valueLabel,
|
|
color: item.color,
|
|
};
|
|
}
|
|
// valueId도 키로 추가 (숫자 ID 저장 시 라벨 표시용)
|
|
if (item.valueId !== undefined && item.valueId !== null) {
|
|
mapping[String(item.valueId)] = {
|
|
label: item.valueLabel,
|
|
color: item.color,
|
|
};
|
|
}
|
|
// 자식 노드도 재귀적으로 처리
|
|
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
|
flattenTree(item.children);
|
|
}
|
|
});
|
|
};
|
|
|
|
flattenTree(response.data.data);
|
|
|
|
if (Object.keys(mapping).length > 0) {
|
|
// 🆕 원래 컬럼명(item_info.material)으로 매핑 저장
|
|
mappings[columnName] = mapping;
|
|
} else {
|
|
// 매핑 데이터가 비어있음 - 해당 컬럼에 카테고리 값이 없음
|
|
}
|
|
}
|
|
} catch {
|
|
// 카테고리 값 로드 실패 - 무시
|
|
}
|
|
}
|
|
|
|
// 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드
|
|
// 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출
|
|
const joinedColumns =
|
|
tableConfig.columns?.filter((col) => col.columnName?.includes(".")).map((col) => col.columnName) || [];
|
|
|
|
// 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material)
|
|
const additionalJoinColumns =
|
|
tableConfig.columns
|
|
?.filter((col: any) => col.additionalJoinInfo?.referenceTable)
|
|
.map((col: any) => ({
|
|
columnName: col.columnName, // 예: item_code_material
|
|
referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info
|
|
// joinAlias에서 실제 컬럼명 추출 (item_code_material → material)
|
|
actualColumn:
|
|
col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, "") ||
|
|
col.columnName,
|
|
})) || [];
|
|
|
|
// 조인 테이블별로 그룹화
|
|
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
|
|
|
|
// "테이블명.컬럼명" 형태 처리
|
|
for (const joinedColumn of joinedColumns) {
|
|
const parts = joinedColumn.split(".");
|
|
if (parts.length !== 2) continue;
|
|
|
|
const joinedTable = parts[0];
|
|
const joinedColumnName = parts[1];
|
|
|
|
if (!joinedTableColumns[joinedTable]) {
|
|
joinedTableColumns[joinedTable] = [];
|
|
}
|
|
joinedTableColumns[joinedTable].push({
|
|
columnName: joinedColumn,
|
|
actualColumn: joinedColumnName,
|
|
});
|
|
}
|
|
|
|
// additionalJoinInfo 형태 처리
|
|
for (const col of additionalJoinColumns) {
|
|
if (!joinedTableColumns[col.referenceTable]) {
|
|
joinedTableColumns[col.referenceTable] = [];
|
|
}
|
|
joinedTableColumns[col.referenceTable].push({
|
|
columnName: col.columnName, // 예: item_code_material
|
|
actualColumn: col.actualColumn, // 예: material
|
|
});
|
|
}
|
|
|
|
// 조인된 테이블별로 inputType 정보 가져오기
|
|
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
|
|
|
for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) {
|
|
try {
|
|
// 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용)
|
|
const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable);
|
|
|
|
for (const col of columns) {
|
|
const inputTypeInfo = inputTypes.find((it: any) => it.columnName === col.actualColumn);
|
|
|
|
// 컬럼명 그대로 저장 (item_code_material 또는 item_info.material)
|
|
newJoinedColumnMeta[col.columnName] = {
|
|
inputType: inputTypeInfo?.inputType,
|
|
};
|
|
|
|
// inputType이 category인 경우 카테고리 매핑 로드
|
|
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
|
|
try {
|
|
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`);
|
|
|
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
|
|
|
response.data.data.forEach((item: any) => {
|
|
// valueCode로 매핑
|
|
if (item.valueCode) {
|
|
const key = String(item.valueCode);
|
|
mapping[key] = {
|
|
label: item.valueLabel,
|
|
color: item.color,
|
|
};
|
|
}
|
|
// valueId도 키로 추가 (숫자 ID 저장 시 라벨 표시용)
|
|
if (item.valueId !== undefined && item.valueId !== null) {
|
|
mapping[String(item.valueId)] = {
|
|
label: item.valueLabel,
|
|
color: item.color,
|
|
};
|
|
}
|
|
});
|
|
|
|
if (Object.keys(mapping).length > 0) {
|
|
mappings[col.columnName] = mapping;
|
|
}
|
|
}
|
|
} catch {
|
|
// 조인 테이블 카테고리 없음 - 무시
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// 조인 테이블 inputType 로드 실패 - 무시
|
|
}
|
|
}
|
|
|
|
// 조인 컬럼 메타데이터 상태 업데이트
|
|
if (Object.keys(newJoinedColumnMeta).length > 0) {
|
|
setJoinedColumnMeta(newJoinedColumnMeta);
|
|
}
|
|
|
|
// 🆕 카테고리 연쇄관계 매핑 로드 (category_value_cascading_mapping)
|
|
try {
|
|
const cascadingResponse = await apiClient.get(
|
|
`/category-value-cascading/table/${tableConfig.selectedTable}/mappings`,
|
|
);
|
|
if (cascadingResponse.data.success && cascadingResponse.data.data) {
|
|
const cascadingMappings = cascadingResponse.data.data;
|
|
|
|
// 각 자식 컬럼에 대한 매핑 추가
|
|
for (const [columnName, columnMappings] of Object.entries(
|
|
cascadingMappings as Record<string, Array<{ code: string; label: string }>>,
|
|
)) {
|
|
if (!mappings[columnName]) {
|
|
mappings[columnName] = {};
|
|
}
|
|
// 연쇄관계 매핑 추가
|
|
for (const item of columnMappings) {
|
|
mappings[columnName][item.code] = {
|
|
label: item.label,
|
|
color: undefined, // 연쇄관계는 색상 없음
|
|
};
|
|
}
|
|
}
|
|
// 카테고리 연쇄관계 매핑 로드 완료
|
|
}
|
|
} catch {
|
|
// 연쇄관계 매핑이 없는 경우 무시
|
|
}
|
|
|
|
if (Object.keys(mappings).length > 0) {
|
|
setCategoryMappings(mappings);
|
|
setCategoryMappingsKey((prev) => prev + 1);
|
|
}
|
|
} catch {
|
|
// 카테고리 매핑 로드 실패 - 무시
|
|
}
|
|
};
|
|
|
|
loadCategoryMappings();
|
|
}, [
|
|
tableConfig.selectedTable,
|
|
categoryColumns.length,
|
|
JSON.stringify(categoryColumns),
|
|
JSON.stringify(tableConfig.columns),
|
|
columnMeta,
|
|
]);
|
|
|
|
// ========================================
|
|
// 데이터 가져오기
|
|
// ========================================
|
|
|
|
const fetchTableDataInternal = useCallback(async () => {
|
|
if (!tableConfig.selectedTable || isDesignMode) {
|
|
setData([]);
|
|
setTotalPages(0);
|
|
setTotalItems(0);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const page = tableConfig.pagination?.currentPage || currentPage;
|
|
const pageSize = localPageSize;
|
|
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
|
|
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
|
|
const sortOrder = sortColumn ? sortDirection : (tableConfig.defaultSort?.direction || sortDirection);
|
|
const search = searchTerm || undefined;
|
|
|
|
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
|
const linkedFilterValues: Record<string, any> = {};
|
|
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
|
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
|
|
|
if (splitPanelContext) {
|
|
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
|
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
|
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
|
(filter) =>
|
|
filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") ||
|
|
filter.targetColumn === tableConfig.selectedTable,
|
|
);
|
|
|
|
// 좌측 데이터 선택 여부 확인
|
|
hasSelectedLeftData =
|
|
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
|
|
|
const allLinkedFilters = splitPanelContext.getLinkedFilterValues();
|
|
|
|
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
|
// 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함
|
|
for (const [key, value] of Object.entries(allLinkedFilters)) {
|
|
if (key.includes(".")) {
|
|
const [tableName, columnName] = key.split(".");
|
|
if (tableName === tableConfig.selectedTable) {
|
|
// 연결 필터는 코드 값이므로 equals 연산자 사용
|
|
linkedFilterValues[columnName] = { value, operator: "equals" };
|
|
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
|
|
}
|
|
} else {
|
|
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals)
|
|
linkedFilterValues[key] = { value, operator: "equals" };
|
|
}
|
|
}
|
|
|
|
// 🆕 자동 컬럼 매칭: linkedFilters가 설정되어 있지 않아도
|
|
// 우측 화면(splitPanelPosition === "right")이고 좌측 데이터가 선택되어 있으면
|
|
// 동일한 컬럼명이 있는 경우 자동으로 필터링 적용
|
|
if (
|
|
splitPanelPosition === "right" &&
|
|
hasSelectedLeftData &&
|
|
Object.keys(linkedFilterValues).length === 0 &&
|
|
!hasLinkedFiltersConfigured
|
|
) {
|
|
const leftData = splitPanelContext.selectedLeftData!;
|
|
const tableColumns = (tableConfig.columns || []).map((col) => col.columnName);
|
|
|
|
// 좌측 데이터의 컬럼 중 현재 테이블에 동일한 컬럼이 있는지 확인
|
|
for (const [colName, colValue] of Object.entries(leftData)) {
|
|
// null, undefined, 빈 문자열 제외
|
|
if (colValue === null || colValue === undefined || colValue === "") continue;
|
|
// id, objid 등 기본 키는 제외 (너무 일반적인 컬럼명)
|
|
if (colName === "id" || colName === "objid" || colName === "company_code") continue;
|
|
|
|
// 현재 테이블에 동일한 컬럼이 있는지 확인
|
|
if (tableColumns.includes(colName)) {
|
|
// 자동 컬럼 매칭도 equals 연산자 사용
|
|
linkedFilterValues[colName] = { value: colValue, operator: "equals" };
|
|
hasLinkedFiltersConfigured = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
|
|
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
|
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
|
|
setData([]);
|
|
setTotalItems(0);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
|
|
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
|
if (isRelatedButtonTarget && !relatedButtonFilter) {
|
|
setData([]);
|
|
setTotalItems(0);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// RelatedDataButtons 필터 값 준비
|
|
const relatedButtonFilterValues: Record<string, any> = {};
|
|
if (relatedButtonFilter) {
|
|
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
|
|
value: relatedButtonFilter.filterValue,
|
|
operator: "equals",
|
|
};
|
|
}
|
|
|
|
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
|
|
const filters = {
|
|
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
|
|
...linkedFilterValues,
|
|
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
|
};
|
|
const hasFilters = Object.keys(filters).length > 0;
|
|
|
|
// 🆕 REST API 데이터 소스 처리
|
|
const isRestApiTable =
|
|
tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_");
|
|
|
|
let response: any;
|
|
|
|
if (isRestApiTable) {
|
|
// REST API 데이터 소스인 경우
|
|
const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/);
|
|
const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null;
|
|
|
|
if (connectionId) {
|
|
// REST API 연결 정보 가져오기 및 데이터 조회
|
|
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
|
|
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
|
connectionId,
|
|
undefined, // endpoint - 연결 정보에서 가져옴
|
|
"response", // jsonPath - 기본값 response
|
|
);
|
|
|
|
response = {
|
|
data: restApiData.rows || [],
|
|
total: restApiData.total || restApiData.rows?.length || 0,
|
|
totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize),
|
|
};
|
|
} else {
|
|
throw new Error("REST API 연결 ID를 찾을 수 없습니다.");
|
|
}
|
|
} else {
|
|
// 일반 DB 테이블인 경우 (기존 로직)
|
|
const entityJoinColumns = (tableConfig.columns || [])
|
|
.filter((col) => col.additionalJoinInfo)
|
|
.map((col) => ({
|
|
sourceTable: col.additionalJoinInfo!.sourceTable,
|
|
sourceColumn: col.additionalJoinInfo!.sourceColumn,
|
|
joinAlias: col.additionalJoinInfo!.joinAlias,
|
|
referenceTable: col.additionalJoinInfo!.referenceTable,
|
|
}));
|
|
|
|
// 🎯 화면별 엔티티 표시 설정 수집
|
|
const screenEntityConfigs: Record<string, any> = {};
|
|
(tableConfig.columns || [])
|
|
.filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0)
|
|
.forEach((col) => {
|
|
screenEntityConfigs[col.columnName] = {
|
|
displayColumns: col.entityDisplayConfig!.displayColumns,
|
|
separator: col.entityDisplayConfig!.separator || " - ",
|
|
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
|
|
joinTable: col.entityDisplayConfig!.joinTable,
|
|
};
|
|
});
|
|
|
|
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
|
let excludeFilterParam: any = undefined;
|
|
if (tableConfig.excludeFilter?.enabled) {
|
|
const excludeConfig = tableConfig.excludeFilter;
|
|
let filterValue: any = undefined;
|
|
|
|
// 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널)
|
|
if (excludeConfig.filterColumn && excludeConfig.filterValueField) {
|
|
const fieldName = excludeConfig.filterValueField;
|
|
|
|
// 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용)
|
|
if (propFormData && propFormData[fieldName]) {
|
|
filterValue = propFormData[fieldName];
|
|
}
|
|
// 2순위: URL 파라미터에서 값 가져오기
|
|
else if (typeof window !== "undefined") {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
filterValue = urlParams.get(fieldName);
|
|
}
|
|
// 3순위: 분할 패널 부모 데이터에서 값 가져오기
|
|
if (!filterValue && splitPanelContext?.selectedLeftData) {
|
|
filterValue = splitPanelContext.selectedLeftData[fieldName];
|
|
}
|
|
}
|
|
|
|
if (filterValue || !excludeConfig.filterColumn) {
|
|
excludeFilterParam = {
|
|
enabled: true,
|
|
referenceTable: excludeConfig.referenceTable,
|
|
referenceColumn: excludeConfig.referenceColumn,
|
|
sourceColumn: excludeConfig.sourceColumn,
|
|
filterColumn: excludeConfig.filterColumn,
|
|
filterValue: filterValue,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
|
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
|
page,
|
|
size: pageSize,
|
|
sortBy,
|
|
sortOrder,
|
|
search: hasFilters ? filters : undefined,
|
|
enableEntityJoin: true,
|
|
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
|
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달
|
|
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
|
excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달
|
|
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만)
|
|
});
|
|
|
|
// 실제 데이터의 item_number만 추출하여 중복 확인
|
|
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
|
|
const uniqueItemNumbers = [...new Set(itemNumbers)];
|
|
|
|
// console.log("✅ [TableList] API 응답 받음");
|
|
// console.log(` - dataLength: ${response.data?.length || 0}`);
|
|
// console.log(` - total: ${response.total}`);
|
|
// console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
|
|
// console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
|
|
// console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
|
|
|
|
setData(response.data || []);
|
|
setTotalPages(response.totalPages || 0);
|
|
setTotalItems(response.total || 0);
|
|
setError(null);
|
|
|
|
// 🎯 Store에 필터 조건 저장 (엑셀 다운로드용)
|
|
// tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음)
|
|
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
|
const labels: Record<string, string> = {};
|
|
cols.forEach((col) => {
|
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
|
});
|
|
|
|
tableDisplayStore.setTableData(
|
|
tableConfig.selectedTable,
|
|
response.data || [],
|
|
cols.map((col) => col.columnName),
|
|
sortBy,
|
|
sortOrder,
|
|
{
|
|
filterConditions: filters,
|
|
searchTerm: search,
|
|
visibleColumns: cols.map((col) => col.columnName),
|
|
columnLabels: labels,
|
|
currentPage: page,
|
|
pageSize: pageSize,
|
|
totalItems: response.total || 0,
|
|
},
|
|
);
|
|
}
|
|
} catch (err: any) {
|
|
console.error("데이터 가져오기 실패:", err);
|
|
setData([]);
|
|
setTotalPages(0);
|
|
setTotalItems(0);
|
|
setError(err.message || "데이터를 불러오지 못했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [
|
|
tableConfig.selectedTable,
|
|
tableConfig.pagination?.currentPage,
|
|
tableConfig.columns,
|
|
currentPage,
|
|
localPageSize,
|
|
sortColumn,
|
|
sortDirection,
|
|
searchTerm,
|
|
searchValues,
|
|
isDesignMode,
|
|
// 🆕 우측 화면일 때만 selectedLeftData 변경에 반응 (좌측 테이블은 재조회 불필요)
|
|
splitPanelPosition,
|
|
currentSplitPosition,
|
|
splitPanelContext?.selectedLeftData,
|
|
// 🆕 RelatedDataButtons 필터 추가
|
|
relatedButtonFilter,
|
|
isRelatedButtonTarget,
|
|
// 🆕 프리뷰용 회사 코드 오버라이드
|
|
companyCode,
|
|
]);
|
|
|
|
const fetchTableDataDebounced = useCallback(
|
|
(...args: Parameters<typeof fetchTableDataInternal>) => {
|
|
const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`;
|
|
return debouncedApiCall(key, fetchTableDataInternal, 300)(...args);
|
|
},
|
|
[fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection],
|
|
);
|
|
|
|
// ========================================
|
|
// 이벤트 핸들러
|
|
// ========================================
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
if (newPage < 1 || newPage > totalPages) return;
|
|
setCurrentPage(newPage);
|
|
if (tableConfig.pagination) {
|
|
tableConfig.pagination.currentPage = newPage;
|
|
}
|
|
if (onConfigChange) {
|
|
onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } });
|
|
}
|
|
};
|
|
|
|
const handleSort = (column: string) => {
|
|
let newSortColumn = column;
|
|
let newSortDirection: "asc" | "desc" = "asc";
|
|
|
|
if (sortColumn === column) {
|
|
newSortDirection = sortDirection === "asc" ? "desc" : "asc";
|
|
setSortDirection(newSortDirection);
|
|
} else {
|
|
setSortColumn(column);
|
|
setSortDirection("asc");
|
|
newSortColumn = column;
|
|
newSortDirection = "asc";
|
|
}
|
|
|
|
// 정렬 상태를 localStorage에 저장 (사용자별)
|
|
if (tableConfig.selectedTable && userId) {
|
|
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
|
|
try {
|
|
localStorage.setItem(
|
|
storageKey,
|
|
JSON.stringify({
|
|
column: newSortColumn,
|
|
direction: newSortDirection,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
// 정렬 상태 저장 실패
|
|
}
|
|
}
|
|
|
|
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
|
|
if (onSelectedRowsChange) {
|
|
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
|
|
|
// 1단계: 데이터를 정렬
|
|
const sortedData = [...data].sort((a, b) => {
|
|
const aVal = a[newSortColumn];
|
|
const bVal = b[newSortColumn];
|
|
|
|
// null/undefined 처리
|
|
if (aVal == null && bVal == null) return 0;
|
|
if (aVal == null) return 1;
|
|
if (bVal == null) return -1;
|
|
|
|
// 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교)
|
|
const aNum = Number(aVal);
|
|
const bNum = Number(bVal);
|
|
|
|
// 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우
|
|
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") {
|
|
return newSortDirection === "desc" ? bNum - aNum : aNum - bNum;
|
|
}
|
|
|
|
// 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬)
|
|
const aStr = String(aVal).toLowerCase();
|
|
const bStr = String(bVal).toLowerCase();
|
|
|
|
// 자연스러운 정렬 (숫자 포함 문자열)
|
|
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: "base" });
|
|
return newSortDirection === "desc" ? -comparison : comparison;
|
|
});
|
|
|
|
// 2단계: 정렬된 데이터를 컬럼 순서대로 재정렬
|
|
// tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음)
|
|
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
|
const reorderedData = sortedData.map((row: any) => {
|
|
const reordered: any = {};
|
|
cols.forEach((col) => {
|
|
if (col.columnName in row) {
|
|
reordered[col.columnName] = row[col.columnName];
|
|
}
|
|
});
|
|
// 나머지 컬럼 추가
|
|
Object.keys(row).forEach((key) => {
|
|
if (!(key in reordered)) {
|
|
reordered[key] = row[key];
|
|
}
|
|
});
|
|
return reordered;
|
|
});
|
|
|
|
onSelectedRowsChange(
|
|
Array.from(selectedRows),
|
|
selectedRowsData,
|
|
newSortColumn,
|
|
newSortDirection,
|
|
columnOrder.length > 0 ? columnOrder : undefined,
|
|
reorderedData,
|
|
);
|
|
|
|
// 전역 저장소에 정렬된 데이터 저장
|
|
if (tableConfig.selectedTable) {
|
|
const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName)).filter(
|
|
(col) => col !== "__checkbox__",
|
|
);
|
|
|
|
// 컬럼 라벨 정보도 함께 저장
|
|
const labels: Record<string, string> = {};
|
|
cols.forEach((col) => {
|
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
|
});
|
|
|
|
tableDisplayStore.setTableData(
|
|
tableConfig.selectedTable,
|
|
reorderedData,
|
|
cleanColumnOrder,
|
|
newSortColumn,
|
|
newSortDirection,
|
|
{
|
|
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
|
|
searchTerm: searchTerm || undefined,
|
|
visibleColumns: cols.map((col) => col.columnName),
|
|
columnLabels: labels,
|
|
currentPage: currentPage,
|
|
pageSize: localPageSize,
|
|
totalItems: totalItems,
|
|
},
|
|
);
|
|
}
|
|
} else {
|
|
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
|
}
|
|
};
|
|
|
|
const handleSearchValueChange = (columnName: string, value: any) => {
|
|
setSearchValues((prev) => ({ ...prev, [columnName]: value }));
|
|
};
|
|
|
|
const handleAdvancedSearch = () => {
|
|
setCurrentPage(1);
|
|
fetchTableDataDebounced();
|
|
};
|
|
|
|
const handleClearAdvancedFilters = useCallback(() => {
|
|
// 상태를 초기화하고 useEffect로 데이터 새로고침
|
|
setSearchValues({});
|
|
setCurrentPage(1);
|
|
|
|
// 강제로 데이터 새로고침 트리거
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
}, [searchValues]);
|
|
|
|
const handleRefresh = () => {
|
|
fetchTableDataDebounced();
|
|
};
|
|
|
|
const getRowKey = (row: any, index: number) => {
|
|
return row.id || row.uuid || `row-${index}`;
|
|
};
|
|
|
|
const handleRowSelection = (rowKey: string, checked: boolean) => {
|
|
const newSelectedRows = new Set(selectedRows);
|
|
if (checked) {
|
|
newSelectedRows.add(rowKey);
|
|
} else {
|
|
newSelectedRows.delete(rowKey);
|
|
}
|
|
setSelectedRows(newSelectedRows);
|
|
|
|
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
|
if (onSelectedRowsChange) {
|
|
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection);
|
|
}
|
|
if (onFormDataChange) {
|
|
onFormDataChange({
|
|
selectedRows: Array.from(newSelectedRows),
|
|
selectedRowsData,
|
|
});
|
|
}
|
|
|
|
// 🆕 리피터 컨테이너/집계 위젯 연동용 V2 이벤트 발생
|
|
v2EventBus.emitSync(V2_EVENTS.TABLE_DATA_CHANGE, {
|
|
tableName: tableConfig.selectedTable || "",
|
|
data: selectedRowsData,
|
|
totalCount: selectedRowsData.length,
|
|
source: component.id || "table-list",
|
|
});
|
|
|
|
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
|
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
|
const modalItems = selectedRowsData.map((row, idx) => ({
|
|
id: getRowKey(row, idx),
|
|
originalData: row,
|
|
additionalData: {},
|
|
}));
|
|
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
|
|
});
|
|
} else if (tableConfig.selectedTable && selectedRowsData.length === 0) {
|
|
// 선택 해제 시 데이터 제거
|
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
|
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
|
|
});
|
|
}
|
|
|
|
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
|
setIsAllSelected(allRowsSelected && filteredData.length > 0);
|
|
};
|
|
|
|
const handleSelectAll = (checked: boolean) => {
|
|
if (checked) {
|
|
const allKeys = filteredData.map((row, index) => getRowKey(row, index));
|
|
const newSelectedRows = new Set(allKeys);
|
|
setSelectedRows(newSelectedRows);
|
|
setIsAllSelected(true);
|
|
|
|
if (onSelectedRowsChange) {
|
|
onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection);
|
|
}
|
|
if (onFormDataChange) {
|
|
onFormDataChange({
|
|
selectedRows: Array.from(newSelectedRows),
|
|
selectedRowsData: filteredData,
|
|
});
|
|
}
|
|
|
|
// 🆕 리피터 컨테이너/집계 위젯 연동용 V2 이벤트 발생
|
|
v2EventBus.emitSync(V2_EVENTS.TABLE_DATA_CHANGE, {
|
|
tableName: tableConfig.selectedTable || "",
|
|
data: filteredData,
|
|
totalCount: filteredData.length,
|
|
source: component.id || "table-list",
|
|
});
|
|
|
|
// 🆕 modalDataStore에 전체 데이터 저장
|
|
if (tableConfig.selectedTable && filteredData.length > 0) {
|
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
|
const modalItems = filteredData.map((row, idx) => ({
|
|
id: getRowKey(row, idx),
|
|
originalData: row,
|
|
additionalData: {},
|
|
}));
|
|
|
|
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
|
|
});
|
|
}
|
|
} else {
|
|
setSelectedRows(new Set());
|
|
setIsAllSelected(false);
|
|
|
|
if (onSelectedRowsChange) {
|
|
onSelectedRowsChange([], [], sortColumn || undefined, sortDirection);
|
|
}
|
|
if (onFormDataChange) {
|
|
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
|
}
|
|
|
|
// 🆕 리피터 컨테이너/집계 위젯 연동용 V2 이벤트 발생 (선택 해제)
|
|
v2EventBus.emitSync(V2_EVENTS.TABLE_DATA_CHANGE, {
|
|
tableName: tableConfig.selectedTable || "",
|
|
data: [],
|
|
totalCount: 0,
|
|
source: component.id || "table-list",
|
|
});
|
|
|
|
// 🆕 modalDataStore 데이터 제거
|
|
if (tableConfig.selectedTable) {
|
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
|
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
|
|
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) {
|
|
return;
|
|
}
|
|
|
|
// 행 선택/해제 토글
|
|
const rowKey = getRowKey(row, index);
|
|
const isCurrentlySelected = selectedRows.has(rowKey);
|
|
|
|
handleRowSelection(rowKey, !isCurrentlySelected);
|
|
|
|
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
|
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
|
// currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
|
|
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
|
|
|
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
|
if (!isCurrentlySelected) {
|
|
// 선택된 경우: 데이터 저장
|
|
splitPanelContext.setSelectedLeftData(row);
|
|
} else {
|
|
// 선택 해제된 경우: 데이터 초기화
|
|
splitPanelContext.setSelectedLeftData(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
|
|
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
|
|
// 현재 편집 중인 셀을 클릭한 경우 포커스 이동 방지 (select 드롭다운 등이 닫히는 것 방지)
|
|
if (editingCell?.rowIndex === rowIndex && editingCell?.colIndex === colIndex) {
|
|
return;
|
|
}
|
|
|
|
setFocusedCell({ rowIndex, colIndex });
|
|
tableContainerRef.current?.focus();
|
|
|
|
const row = filteredData[rowIndex];
|
|
if (!row) return;
|
|
|
|
// 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵
|
|
const column = visibleColumns[colIndex];
|
|
if (column?.columnName === "__checkbox__") return;
|
|
|
|
const rowKey = getRowKey(row, rowIndex);
|
|
const isCurrentlySelected = selectedRows.has(rowKey);
|
|
|
|
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
|
|
|
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
|
// 분할 패널 좌측: 단일 행 선택 모드
|
|
if (!isCurrentlySelected) {
|
|
setSelectedRows(new Set([rowKey]));
|
|
setIsAllSelected(false);
|
|
|
|
splitPanelContext.setSelectedLeftData(row);
|
|
|
|
if (onSelectedRowsChange) {
|
|
onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection);
|
|
}
|
|
if (onFormDataChange) {
|
|
onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] });
|
|
}
|
|
}
|
|
} else {
|
|
// 일반 모드: 행 선택/해제 토글
|
|
handleRowSelection(rowKey, !isCurrentlySelected);
|
|
|
|
if (splitPanelContext && effectiveSplitPosition === "left") {
|
|
if (!isCurrentlySelected) {
|
|
splitPanelContext.setSelectedLeftData(row);
|
|
} else {
|
|
splitPanelContext.setSelectedLeftData(null);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용
|
|
const handleCellDoubleClick = useCallback(
|
|
(rowIndex: number, colIndex: number, columnName: string, value: any) => {
|
|
// 체크박스 컬럼은 편집 불가
|
|
if (columnName === "__checkbox__") return;
|
|
|
|
// 🆕 편집 불가 컬럼 체크
|
|
const column = visibleColumns.find((col) => col.columnName === columnName);
|
|
if (column?.editable === false) {
|
|
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
|
|
return;
|
|
}
|
|
|
|
setEditingCell({ rowIndex, colIndex, columnName, originalValue: value });
|
|
setEditingValue(value !== null && value !== undefined ? String(value) : "");
|
|
setFocusedCell({ rowIndex, colIndex });
|
|
},
|
|
[visibleColumns],
|
|
);
|
|
|
|
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
|
|
const startEditingRef = useRef<() => void>(() => {});
|
|
|
|
// 🆕 각 컬럼의 고유값 목록 계산
|
|
const columnUniqueValues = useMemo(() => {
|
|
const result: Record<string, string[]> = {};
|
|
|
|
if (data.length === 0) return result;
|
|
|
|
(tableConfig.columns || []).forEach((column: { columnName: string }) => {
|
|
if (column.columnName === "__checkbox__") return;
|
|
|
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
|
const values = new Set<string>();
|
|
|
|
data.forEach((row) => {
|
|
const val = row[mappedColumnName];
|
|
if (val !== null && val !== undefined && val !== "") {
|
|
values.add(String(val));
|
|
}
|
|
});
|
|
|
|
result[column.columnName] = Array.from(values).sort();
|
|
});
|
|
|
|
return result;
|
|
}, [data, tableConfig.columns, joinColumnMapping]);
|
|
|
|
// 🆕 헤더 필터 토글
|
|
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
|
|
setHeaderFilters((prev) => {
|
|
const current = prev[columnName] || new Set<string>();
|
|
const newSet = new Set(current);
|
|
|
|
if (newSet.has(value)) {
|
|
newSet.delete(value);
|
|
} else {
|
|
newSet.add(value);
|
|
}
|
|
|
|
return { ...prev, [columnName]: newSet };
|
|
});
|
|
}, []);
|
|
|
|
// 🆕 헤더 필터 초기화
|
|
const clearHeaderFilter = useCallback((columnName: string) => {
|
|
setHeaderFilters((prev) => {
|
|
const newFilters = { ...prev };
|
|
delete newFilters[columnName];
|
|
return newFilters;
|
|
});
|
|
setOpenFilterColumn(null);
|
|
}, []);
|
|
|
|
// 🆕 모든 헤더 필터 초기화
|
|
const clearAllHeaderFilters = useCallback(() => {
|
|
setHeaderFilters({});
|
|
setOpenFilterColumn(null);
|
|
}, []);
|
|
|
|
// 🆕 데이터 요약 (Total Summaries) 설정
|
|
// 형식: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } }
|
|
const summaryConfig = useMemo(() => {
|
|
const config: Record<string, { type: string; label?: string }> = {};
|
|
|
|
// tableConfig에서 summary 설정 읽기
|
|
if (tableConfig.summaries) {
|
|
tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => {
|
|
config[summary.columnName] = { type: summary.type, label: summary.label };
|
|
});
|
|
}
|
|
|
|
return config;
|
|
}, [tableConfig.summaries]);
|
|
|
|
// 🆕 요약 데이터 계산
|
|
const summaryData = useMemo(() => {
|
|
if (Object.keys(summaryConfig).length === 0 || data.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const result: Record<string, { value: number | string; label: string }> = {};
|
|
|
|
Object.entries(summaryConfig).forEach(([columnName, config]) => {
|
|
const values = data
|
|
.map((row) => {
|
|
const mappedColumnName = joinColumnMapping[columnName] || columnName;
|
|
const val = row[mappedColumnName];
|
|
return typeof val === "number" ? val : parseFloat(val);
|
|
})
|
|
.filter((v) => !isNaN(v));
|
|
|
|
let value: number | string = 0;
|
|
let label = config.label || "";
|
|
|
|
switch (config.type) {
|
|
case "sum":
|
|
value = values.reduce((acc, v) => acc + v, 0);
|
|
label = label || "합계";
|
|
break;
|
|
case "avg":
|
|
value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0;
|
|
label = label || "평균";
|
|
break;
|
|
case "count":
|
|
value = data.length;
|
|
label = label || "개수";
|
|
break;
|
|
case "min":
|
|
value = values.length > 0 ? Math.min(...values) : 0;
|
|
label = label || "최소";
|
|
break;
|
|
case "max":
|
|
value = values.length > 0 ? Math.max(...values) : 0;
|
|
label = label || "최대";
|
|
break;
|
|
default:
|
|
value = 0;
|
|
}
|
|
|
|
result[columnName] = { value, label };
|
|
});
|
|
|
|
return result;
|
|
}, [data, summaryConfig, joinColumnMapping]);
|
|
|
|
// 🆕 편집 취소
|
|
const cancelEditing = useCallback(() => {
|
|
setEditingCell(null);
|
|
setEditingValue("");
|
|
tableContainerRef.current?.focus();
|
|
}, []);
|
|
|
|
// 🆕 편집 저장 (즉시 저장 또는 배치 저장)
|
|
const saveEditing = useCallback(async () => {
|
|
if (!editingCell) return;
|
|
|
|
const { rowIndex, columnName, originalValue } = editingCell;
|
|
const newValue = editingValue;
|
|
|
|
// 값이 변경되지 않았으면 그냥 닫기
|
|
if (String(originalValue ?? "") === newValue) {
|
|
setCellValidationError(rowIndex, columnName, null); // 에러 초기화
|
|
cancelEditing();
|
|
return;
|
|
}
|
|
|
|
// 현재 행 데이터 가져오기
|
|
const row = data[rowIndex];
|
|
if (!row || !tableConfig.selectedTable) {
|
|
cancelEditing();
|
|
return;
|
|
}
|
|
|
|
// 🆕 유효성 검사 실행
|
|
const validationError = validateValue(newValue === "" ? null : newValue, columnName, row);
|
|
if (validationError) {
|
|
setCellValidationError(rowIndex, columnName, validationError);
|
|
toast.error(validationError);
|
|
// 편집 상태 유지 (에러 수정 가능하도록)
|
|
return;
|
|
}
|
|
// 유효성 통과 시 에러 초기화
|
|
setCellValidationError(rowIndex, columnName, null);
|
|
|
|
// 기본 키 필드 찾기 (id 또는 첫 번째 컬럼)
|
|
const primaryKeyField = tableConfig.primaryKey || "id";
|
|
const primaryKeyValue = row[primaryKeyField];
|
|
|
|
if (primaryKeyValue === undefined || primaryKeyValue === null) {
|
|
console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField);
|
|
cancelEditing();
|
|
return;
|
|
}
|
|
|
|
// 🆕 배치 모드: 변경사항을 pending에 저장
|
|
if (editMode === "batch") {
|
|
const changeKey = `${rowIndex}-${columnName}`;
|
|
setPendingChanges((prev) => {
|
|
const newMap = new Map(prev);
|
|
newMap.set(changeKey, {
|
|
rowIndex,
|
|
columnName,
|
|
originalValue,
|
|
newValue: newValue === "" ? null : newValue,
|
|
primaryKeyValue,
|
|
});
|
|
return newMap;
|
|
});
|
|
|
|
// 로컬 수정 데이터 업데이트 (화면 표시용)
|
|
setLocalEditedData((prev) => ({
|
|
...prev,
|
|
[rowIndex]: {
|
|
...(prev[rowIndex] || {}),
|
|
[columnName]: newValue === "" ? null : newValue,
|
|
},
|
|
}));
|
|
|
|
cancelEditing();
|
|
return;
|
|
}
|
|
|
|
// 즉시 모드: 바로 저장
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
|
|
await apiClient.put("/dynamic-form/update-field", {
|
|
tableName: tableConfig.selectedTable,
|
|
keyField: primaryKeyField,
|
|
keyValue: primaryKeyValue,
|
|
updateField: columnName,
|
|
updateValue: newValue === "" ? null : newValue,
|
|
});
|
|
|
|
// 데이터 새로고침 트리거
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
} catch (error) {
|
|
// 셀 편집 저장 실패
|
|
}
|
|
|
|
cancelEditing();
|
|
}, [
|
|
editingCell,
|
|
editingValue,
|
|
data,
|
|
tableConfig.selectedTable,
|
|
tableConfig.primaryKey,
|
|
cancelEditing,
|
|
editMode,
|
|
pendingChanges.size,
|
|
]);
|
|
|
|
// 🆕 배치 저장: 모든 변경사항 한번에 저장
|
|
const saveBatchChanges = useCallback(async () => {
|
|
if (pendingChanges.size === 0) {
|
|
toast.info("저장할 변경사항이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const primaryKeyField = tableConfig.primaryKey || "id";
|
|
|
|
// 모든 변경사항 저장
|
|
const savePromises = Array.from(pendingChanges.values()).map((change) =>
|
|
apiClient.put("/dynamic-form/update-field", {
|
|
tableName: tableConfig.selectedTable,
|
|
keyField: primaryKeyField,
|
|
keyValue: change.primaryKeyValue,
|
|
updateField: change.columnName,
|
|
updateValue: change.newValue,
|
|
}),
|
|
);
|
|
|
|
await Promise.all(savePromises);
|
|
|
|
// 상태 초기화
|
|
setPendingChanges(new Map());
|
|
setLocalEditedData({});
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
|
|
toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`);
|
|
} catch (error) {
|
|
showErrorToast("데이터 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
}, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]);
|
|
|
|
// 배치 취소: 모든 변경사항 롤백
|
|
const cancelBatchChanges = useCallback(() => {
|
|
if (pendingChanges.size === 0) return;
|
|
|
|
setPendingChanges(new Map());
|
|
setLocalEditedData({});
|
|
toast.info("변경사항이 취소되었습니다.");
|
|
}, [pendingChanges.size]);
|
|
|
|
// 🆕 특정 셀이 수정되었는지 확인
|
|
const isCellModified = useCallback(
|
|
(rowIndex: number, columnName: string) => {
|
|
return pendingChanges.has(`${rowIndex}-${columnName}`);
|
|
},
|
|
[pendingChanges],
|
|
);
|
|
|
|
// 🆕 수정된 셀 값 가져오기 (로컬 수정 데이터 우선)
|
|
const getDisplayValue = useCallback(
|
|
(row: any, rowIndex: number, columnName: string) => {
|
|
const localValue = localEditedData[rowIndex]?.[columnName];
|
|
if (localValue !== undefined) {
|
|
return localValue;
|
|
}
|
|
return row[columnName];
|
|
},
|
|
[localEditedData],
|
|
);
|
|
|
|
// 🆕 유효성 검사 함수
|
|
const validateValue = useCallback(
|
|
(value: any, columnName: string, row: any): string | null => {
|
|
// tableConfig.validation에서 컬럼별 규칙 가져오기
|
|
const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined;
|
|
if (!rules) return null;
|
|
|
|
const strValue = value !== null && value !== undefined ? String(value) : "";
|
|
const numValue = parseFloat(strValue);
|
|
|
|
// 필수 검사
|
|
if (rules.required && (!strValue || strValue.trim() === "")) {
|
|
return rules.customMessage || "필수 입력 항목입니다.";
|
|
}
|
|
|
|
// 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우)
|
|
if (!strValue || strValue.trim() === "") return null;
|
|
|
|
// 최소값 검사
|
|
if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) {
|
|
return rules.customMessage || `최소값은 ${rules.min}입니다.`;
|
|
}
|
|
|
|
// 최대값 검사
|
|
if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) {
|
|
return rules.customMessage || `최대값은 ${rules.max}입니다.`;
|
|
}
|
|
|
|
// 최소 길이 검사
|
|
if (rules.minLength !== undefined && strValue.length < rules.minLength) {
|
|
return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`;
|
|
}
|
|
|
|
// 최대 길이 검사
|
|
if (rules.maxLength !== undefined && strValue.length > rules.maxLength) {
|
|
return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`;
|
|
}
|
|
|
|
// 패턴 검사
|
|
if (rules.pattern && !rules.pattern.test(strValue)) {
|
|
return rules.customMessage || "입력 형식이 올바르지 않습니다.";
|
|
}
|
|
|
|
// 커스텀 검증
|
|
if (rules.validate) {
|
|
const customError = rules.validate(value, row);
|
|
if (customError) return customError;
|
|
}
|
|
|
|
return null;
|
|
},
|
|
[tableConfig],
|
|
);
|
|
|
|
// 🆕 셀 유효성 에러 여부 확인
|
|
const getCellValidationError = useCallback(
|
|
(rowIndex: number, columnName: string): string | null => {
|
|
return validationErrors.get(`${rowIndex}-${columnName}`) || null;
|
|
},
|
|
[validationErrors],
|
|
);
|
|
|
|
// 🆕 유효성 검사 에러 설정
|
|
const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => {
|
|
setValidationErrors((prev) => {
|
|
const newMap = new Map(prev);
|
|
const key = `${rowIndex}-${columnName}`;
|
|
if (error) {
|
|
newMap.set(key, error);
|
|
} else {
|
|
newMap.delete(key);
|
|
}
|
|
return newMap;
|
|
});
|
|
}, []);
|
|
|
|
// 🆕 모든 유효성 에러 초기화
|
|
const clearAllValidationErrors = useCallback(() => {
|
|
setValidationErrors(new Map());
|
|
}, []);
|
|
|
|
// 🆕 Excel 내보내기 함수
|
|
const exportToExcel = useCallback(
|
|
(exportAll: boolean = true) => {
|
|
try {
|
|
// 내보낼 데이터 선택 (선택된 행만 또는 전체)
|
|
let exportData: any[];
|
|
if (exportAll) {
|
|
exportData = filteredData;
|
|
} else {
|
|
// 선택된 행만 내보내기
|
|
exportData = filteredData.filter((row, index) => {
|
|
const rowKey = getRowKey(row, index);
|
|
return selectedRows.has(rowKey);
|
|
});
|
|
}
|
|
|
|
if (exportData.length === 0) {
|
|
toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
// 컬럼 정보 가져오기 (체크박스 제외)
|
|
const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
|
|
|
|
// 헤더 행 생성
|
|
const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName);
|
|
|
|
// 데이터 행 생성
|
|
const rows = exportData.map((row) => {
|
|
return exportColumns.map((col) => {
|
|
const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName;
|
|
const value = row[mappedColumnName];
|
|
|
|
// 카테고리 매핑된 값 처리
|
|
if (categoryMappings[col.columnName] && value !== null && value !== undefined) {
|
|
const mapping = categoryMappings[col.columnName][String(value)];
|
|
if (mapping) {
|
|
return mapping.label;
|
|
}
|
|
}
|
|
|
|
// null/undefined 처리
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
|
|
return value;
|
|
});
|
|
});
|
|
|
|
// 워크시트 생성
|
|
const wsData = [headers, ...rows];
|
|
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
|
|
|
// 컬럼 너비 자동 조정
|
|
const colWidths = exportColumns.map((col, idx) => {
|
|
const headerLength = headers[idx]?.length || 10;
|
|
const maxDataLength = Math.max(...rows.map((row) => String(row[idx] ?? "").length));
|
|
return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) };
|
|
});
|
|
ws["!cols"] = colWidths;
|
|
|
|
// 워크북 생성
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터");
|
|
|
|
// 파일명 생성
|
|
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
|
|
|
// 파일 다운로드
|
|
XLSX.writeFile(wb, fileName);
|
|
|
|
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`);
|
|
} catch (error) {
|
|
showErrorToast("Excel 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
},
|
|
[
|
|
filteredData,
|
|
selectedRows,
|
|
visibleColumns,
|
|
columnLabels,
|
|
joinColumnMapping,
|
|
categoryMappings,
|
|
tableLabel,
|
|
tableConfig.selectedTable,
|
|
getRowKey,
|
|
],
|
|
);
|
|
|
|
// 🆕 행 확장/축소 토글
|
|
const toggleRowExpand = useCallback(
|
|
async (rowKey: string, row: any) => {
|
|
setExpandedRows((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(rowKey)) {
|
|
newSet.delete(rowKey);
|
|
} else {
|
|
newSet.add(rowKey);
|
|
// 상세 데이터 로딩 (아직 없는 경우)
|
|
if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) {
|
|
loadDetailData(rowKey, row);
|
|
}
|
|
}
|
|
return newSet;
|
|
});
|
|
},
|
|
[detailData, tableConfig],
|
|
);
|
|
|
|
// 🆕 상세 데이터 로딩
|
|
const loadDetailData = useCallback(
|
|
async (rowKey: string, row: any) => {
|
|
const masterDetailConfig = (tableConfig as any).masterDetail;
|
|
if (!masterDetailConfig?.detailTable) return;
|
|
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
|
|
// masterKey 값 가져오기
|
|
const masterKeyField = masterDetailConfig.masterKey || "id";
|
|
const masterKeyValue = row[masterKeyField];
|
|
|
|
// 상세 테이블에서 데이터 조회
|
|
const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, {
|
|
page: 1,
|
|
size: 100,
|
|
search: {
|
|
[masterDetailConfig.detailKey || masterKeyField]: masterKeyValue,
|
|
},
|
|
autoFilter: true,
|
|
});
|
|
|
|
const details = response.data?.data?.data || [];
|
|
|
|
setDetailData((prev) => ({
|
|
...prev,
|
|
[rowKey]: details,
|
|
}));
|
|
} catch (error) {
|
|
setDetailData((prev) => ({
|
|
...prev,
|
|
[rowKey]: [],
|
|
}));
|
|
}
|
|
},
|
|
[tableConfig],
|
|
);
|
|
|
|
// 🆕 모든 행 확장/축소
|
|
const expandAllRows = useCallback(() => {
|
|
if (expandedRows.size === filteredData.length) {
|
|
// 모두 축소
|
|
setExpandedRows(new Set());
|
|
} else {
|
|
// 모두 확장
|
|
const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index)));
|
|
setExpandedRows(allKeys);
|
|
}
|
|
}, [expandedRows.size, filteredData, getRowKey]);
|
|
|
|
// 🆕 Multi-Level Headers: Band 정보 계산
|
|
const columnBandsInfo = useMemo(() => {
|
|
const bands = (tableConfig as any).columnBands as ColumnBand[] | undefined;
|
|
if (!bands || bands.length === 0) return null;
|
|
|
|
// 각 band의 시작 인덱스와 colspan 계산
|
|
const bandInfo = bands
|
|
.map((band) => {
|
|
const visibleBandColumns = band.columns.filter((colName) =>
|
|
visibleColumns.some((vc) => vc.columnName === colName),
|
|
);
|
|
|
|
const startIndex = visibleColumns.findIndex((vc) => visibleBandColumns.includes(vc.columnName));
|
|
|
|
return {
|
|
caption: band.caption,
|
|
columns: visibleBandColumns,
|
|
colSpan: visibleBandColumns.length,
|
|
startIndex,
|
|
};
|
|
})
|
|
.filter((b) => b.colSpan > 0);
|
|
|
|
// Band에 포함되지 않은 컬럼 찾기
|
|
const bandedColumns = new Set(bands.flatMap((b) => b.columns));
|
|
const unbandedColumns = visibleColumns
|
|
.map((vc, idx) => ({ columnName: vc.columnName, index: idx }))
|
|
.filter((c) => !bandedColumns.has(c.columnName));
|
|
|
|
return {
|
|
bands: bandInfo,
|
|
unbandedColumns,
|
|
hasBands: bandInfo.length > 0,
|
|
};
|
|
}, [tableConfig, visibleColumns]);
|
|
|
|
// 🆕 Cascading Lookups: 연계 드롭다운 옵션 로딩
|
|
const loadCascadingOptions = useCallback(
|
|
async (columnName: string, parentColumnName: string, parentValue: any) => {
|
|
const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName];
|
|
if (!cascadingConfig) return;
|
|
|
|
const cacheKey = `${columnName}_${parentValue}`;
|
|
|
|
// 이미 로딩 중이면 스킵
|
|
if (loadingCascading[cacheKey]) return;
|
|
|
|
// 이미 캐시된 데이터가 있으면 스킵
|
|
if (cascadingOptions[cacheKey]) return;
|
|
|
|
setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true }));
|
|
|
|
try {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
|
|
// API에서 연계 옵션 로딩
|
|
const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, {
|
|
page: 1,
|
|
size: 1000,
|
|
search: {
|
|
[cascadingConfig.parentKeyField || parentColumnName]: parentValue,
|
|
},
|
|
autoFilter: true,
|
|
});
|
|
|
|
const items = response.data?.data?.data || [];
|
|
const options = items.map((item: any) => ({
|
|
value: item[cascadingConfig.valueField || "id"],
|
|
label: item[cascadingConfig.labelField || "name"],
|
|
}));
|
|
|
|
setCascadingOptions((prev) => ({
|
|
...prev,
|
|
[cacheKey]: options,
|
|
}));
|
|
} catch (error) {
|
|
setCascadingOptions((prev) => ({
|
|
...prev,
|
|
[cacheKey]: [],
|
|
}));
|
|
} finally {
|
|
setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false }));
|
|
}
|
|
},
|
|
[tableConfig, cascadingOptions, loadingCascading],
|
|
);
|
|
|
|
// 🆕 Cascading Lookups: 특정 컬럼의 옵션 가져오기
|
|
const getCascadingOptions = useCallback(
|
|
(columnName: string, row: any): { value: string; label: string }[] => {
|
|
const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName];
|
|
if (!cascadingConfig) return [];
|
|
|
|
const parentValue = row[cascadingConfig.parentColumn];
|
|
if (parentValue === undefined || parentValue === null) return [];
|
|
|
|
const cacheKey = `${columnName}_${parentValue}`;
|
|
return cascadingOptions[cacheKey] || [];
|
|
},
|
|
[tableConfig, cascadingOptions],
|
|
);
|
|
|
|
// 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조)
|
|
|
|
// 🆕 Virtual Scrolling: 스크롤 핸들러
|
|
const handleVirtualScroll = useCallback(
|
|
(e: React.UIEvent<HTMLDivElement>) => {
|
|
if (!isVirtualScrollEnabled) return;
|
|
setScrollTop(e.currentTarget.scrollTop);
|
|
},
|
|
[isVirtualScrollEnabled],
|
|
);
|
|
|
|
// 🆕 State Persistence: 통합 상태 저장
|
|
const saveTableState = useCallback(() => {
|
|
if (!tableStateKey) return;
|
|
|
|
const state = {
|
|
columnWidths,
|
|
columnOrder,
|
|
sortColumn,
|
|
sortDirection,
|
|
groupByColumns,
|
|
frozenColumns,
|
|
frozenColumnCount, // 틀고정 컬럼 수 저장
|
|
showGridLines,
|
|
headerFilters: Object.fromEntries(
|
|
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
|
|
),
|
|
pageSize: localPageSize,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
try {
|
|
localStorage.setItem(tableStateKey, JSON.stringify(state));
|
|
} catch (error) {
|
|
console.error("❌ 테이블 상태 저장 실패:", error);
|
|
}
|
|
}, [
|
|
tableStateKey,
|
|
columnWidths,
|
|
columnOrder,
|
|
sortColumn,
|
|
sortDirection,
|
|
groupByColumns,
|
|
frozenColumns,
|
|
frozenColumnCount,
|
|
showGridLines,
|
|
headerFilters,
|
|
localPageSize,
|
|
]);
|
|
|
|
// 🆕 State Persistence: 통합 상태 복원
|
|
const loadTableState = useCallback(() => {
|
|
if (!tableStateKey) return;
|
|
|
|
try {
|
|
const saved = localStorage.getItem(tableStateKey);
|
|
if (!saved) return;
|
|
|
|
const state = JSON.parse(saved);
|
|
|
|
if (state.columnWidths) setColumnWidths(state.columnWidths);
|
|
if (state.columnOrder) setColumnOrder(state.columnOrder);
|
|
if (state.sortColumn !== undefined) setSortColumn(state.sortColumn);
|
|
if (state.sortDirection) setSortDirection(state.sortDirection);
|
|
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
|
|
if (state.frozenColumns) {
|
|
// 체크박스 컬럼이 항상 포함되도록 보장
|
|
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null;
|
|
const restoredFrozenColumns =
|
|
checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
|
|
? [checkboxColumn, ...state.frozenColumns]
|
|
: state.frozenColumns;
|
|
setFrozenColumns(restoredFrozenColumns);
|
|
}
|
|
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원
|
|
if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines);
|
|
if (state.headerFilters) {
|
|
const filters: Record<string, Set<string>> = {};
|
|
Object.entries(state.headerFilters).forEach(([key, values]) => {
|
|
filters[key] = new Set(values as string[]);
|
|
});
|
|
setHeaderFilters(filters);
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ 테이블 상태 복원 실패:", error);
|
|
}
|
|
}, [tableStateKey]);
|
|
|
|
// 🆕 State Persistence: 상태 초기화
|
|
const resetTableState = useCallback(() => {
|
|
if (!tableStateKey) return;
|
|
|
|
try {
|
|
localStorage.removeItem(tableStateKey);
|
|
setColumnWidths({});
|
|
setColumnOrder([]);
|
|
setSortColumn(null);
|
|
setSortDirection("asc");
|
|
setGroupByColumns([]);
|
|
setFrozenColumns([]);
|
|
setShowGridLines(true);
|
|
setHeaderFilters({});
|
|
toast.success("테이블 설정이 초기화되었습니다.");
|
|
} catch (error) {
|
|
console.error("❌ 테이블 상태 초기화 실패:", error);
|
|
}
|
|
}, [tableStateKey]);
|
|
|
|
// 🆕 State Persistence: 컴포넌트 마운트 시 상태 복원
|
|
useEffect(() => {
|
|
loadTableState();
|
|
}, [tableStateKey]); // loadTableState는 의존성에서 제외 (무한 루프 방지)
|
|
|
|
// 🆕 Real-Time Updates: WebSocket 연결
|
|
const connectWebSocket = useCallback(() => {
|
|
if (!isRealTimeEnabled || !tableConfig.selectedTable) return;
|
|
|
|
const wsUrl =
|
|
(tableConfig as any).wsUrl ||
|
|
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`;
|
|
|
|
try {
|
|
setWsConnectionStatus("connecting");
|
|
wsRef.current = new WebSocket(wsUrl);
|
|
|
|
wsRef.current.onopen = () => {
|
|
setWsConnectionStatus("connected");
|
|
};
|
|
|
|
wsRef.current.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
|
|
switch (message.type) {
|
|
case "insert":
|
|
// 새 데이터 추가
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
toast.info("새 데이터가 추가되었습니다.");
|
|
break;
|
|
case "update":
|
|
// 데이터 업데이트
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
toast.info("데이터가 업데이트되었습니다.");
|
|
break;
|
|
case "delete":
|
|
// 데이터 삭제
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
toast.info("데이터가 삭제되었습니다.");
|
|
break;
|
|
case "refresh":
|
|
// 전체 새로고침
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
break;
|
|
default:
|
|
// 알 수 없는 메시지 타입
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
// WebSocket 메시지 파싱 오류
|
|
}
|
|
};
|
|
|
|
wsRef.current.onclose = () => {
|
|
setWsConnectionStatus("disconnected");
|
|
|
|
// 자동 재연결 (5초 후)
|
|
if (isRealTimeEnabled) {
|
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
connectWebSocket();
|
|
}, 5000);
|
|
}
|
|
};
|
|
|
|
wsRef.current.onerror = () => {
|
|
setWsConnectionStatus("disconnected");
|
|
};
|
|
} catch (error) {
|
|
setWsConnectionStatus("disconnected");
|
|
}
|
|
}, [isRealTimeEnabled, tableConfig.selectedTable]);
|
|
|
|
// 🆕 Real-Time Updates: 연결 관리
|
|
useEffect(() => {
|
|
if (isRealTimeEnabled) {
|
|
connectWebSocket();
|
|
}
|
|
|
|
return () => {
|
|
// 정리
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
wsRef.current = null;
|
|
}
|
|
};
|
|
}, [isRealTimeEnabled, tableConfig.selectedTable]);
|
|
|
|
// 🆕 State Persistence: 상태 변경 시 자동 저장 (디바운스)
|
|
useEffect(() => {
|
|
const timeoutId = setTimeout(() => {
|
|
saveTableState();
|
|
}, 1000); // 1초 후 저장 (디바운스)
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [
|
|
columnWidths,
|
|
columnOrder,
|
|
sortColumn,
|
|
sortDirection,
|
|
groupByColumns,
|
|
frozenColumns,
|
|
showGridLines,
|
|
headerFilters,
|
|
]);
|
|
|
|
// 🆕 Clipboard: 선택된 데이터 복사
|
|
const handleCopy = useCallback(async () => {
|
|
try {
|
|
// 선택된 행 데이터 가져오기
|
|
let copyData: any[];
|
|
|
|
if (selectedRows.size > 0) {
|
|
// 선택된 행만
|
|
copyData = filteredData.filter((row, index) => {
|
|
const rowKey = getRowKey(row, index);
|
|
return selectedRows.has(rowKey);
|
|
});
|
|
} else if (focusedCell) {
|
|
// 포커스된 셀만
|
|
const row = filteredData[focusedCell.rowIndex];
|
|
if (row) {
|
|
const column = visibleColumns[focusedCell.colIndex];
|
|
const value = row[column?.columnName];
|
|
await navigator.clipboard.writeText(String(value ?? ""));
|
|
toast.success("셀 복사됨");
|
|
return;
|
|
}
|
|
return;
|
|
} else {
|
|
toast.info("복사할 데이터를 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
// TSV 형식으로 변환 (탭으로 구분)
|
|
const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__");
|
|
const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName);
|
|
const rows = copyData.map((row) =>
|
|
exportColumns
|
|
.map((c) => {
|
|
const value = row[c.columnName];
|
|
return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : "";
|
|
})
|
|
.join("\t"),
|
|
);
|
|
|
|
const tsvContent = [headers.join("\t"), ...rows].join("\n");
|
|
await navigator.clipboard.writeText(tsvContent);
|
|
|
|
toast.success(`${copyData.length}행 복사됨`);
|
|
} catch (error) {
|
|
showErrorToast("클립보드 복사에 실패했습니다", error, { guidance: "브라우저 권한을 확인해 주세요." });
|
|
}
|
|
}, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]);
|
|
|
|
// 🆕 전체 행 선택
|
|
const handleSelectAllRows = useCallback(() => {
|
|
if (selectedRows.size === filteredData.length) {
|
|
// 전체 해제
|
|
setSelectedRows(new Set());
|
|
setIsAllSelected(false);
|
|
} else {
|
|
// 전체 선택
|
|
const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index)));
|
|
setSelectedRows(allKeys);
|
|
setIsAllSelected(true);
|
|
}
|
|
}, [selectedRows.size, filteredData, getRowKey]);
|
|
|
|
// 🆕 Context Menu: 열기
|
|
const handleContextMenu = useCallback((e: React.MouseEvent, rowIndex: number, colIndex: number, row: any) => {
|
|
e.preventDefault();
|
|
setContextMenu({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
rowIndex,
|
|
colIndex,
|
|
row,
|
|
});
|
|
}, []);
|
|
|
|
// 🆕 Context Menu: 닫기
|
|
const closeContextMenu = useCallback(() => {
|
|
setContextMenu(null);
|
|
}, []);
|
|
|
|
// 🆕 Context Menu: 외부 클릭 시 닫기
|
|
useEffect(() => {
|
|
if (contextMenu) {
|
|
const handleClick = () => closeContextMenu();
|
|
document.addEventListener("click", handleClick);
|
|
return () => document.removeEventListener("click", handleClick);
|
|
}
|
|
}, [contextMenu, closeContextMenu]);
|
|
|
|
// 🆕 Search Panel: 통합 검색 실행
|
|
const executeGlobalSearch = useCallback(
|
|
(term: string) => {
|
|
if (!term.trim()) {
|
|
setSearchHighlights(new Set());
|
|
return;
|
|
}
|
|
|
|
const lowerTerm = term.toLowerCase();
|
|
const highlights = new Set<string>();
|
|
|
|
filteredData.forEach((row, rowIndex) => {
|
|
visibleColumns.forEach((col, colIndex) => {
|
|
const value = row[col.columnName];
|
|
if (value !== null && value !== undefined) {
|
|
const strValue = String(value).toLowerCase();
|
|
if (strValue.includes(lowerTerm)) {
|
|
highlights.add(`${rowIndex}-${colIndex}`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
setSearchHighlights(highlights);
|
|
|
|
// 첫 번째 검색 결과로 포커스 이동
|
|
if (highlights.size > 0) {
|
|
const firstHighlight = Array.from(highlights)[0];
|
|
const [rowIdx, colIdx] = firstHighlight.split("-").map(Number);
|
|
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
|
toast.success(`${highlights.size}개 검색 결과`);
|
|
} else {
|
|
toast.info("검색 결과가 없습니다");
|
|
}
|
|
},
|
|
[filteredData, visibleColumns],
|
|
);
|
|
|
|
// 🆕 Search Panel: 다음 검색 결과로 이동
|
|
const goToNextSearchResult = useCallback(() => {
|
|
if (searchHighlights.size === 0) return;
|
|
|
|
const highlightArray = Array.from(searchHighlights).sort((a, b) => {
|
|
const [aRow, aCol] = a.split("-").map(Number);
|
|
const [bRow, bCol] = b.split("-").map(Number);
|
|
if (aRow !== bRow) return aRow - bRow;
|
|
return aCol - bCol;
|
|
});
|
|
|
|
if (!focusedCell) {
|
|
const [rowIdx, colIdx] = highlightArray[0].split("-").map(Number);
|
|
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
|
return;
|
|
}
|
|
|
|
const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`;
|
|
const currentIndex = highlightArray.indexOf(currentKey);
|
|
const nextIndex = (currentIndex + 1) % highlightArray.length;
|
|
const [rowIdx, colIdx] = highlightArray[nextIndex].split("-").map(Number);
|
|
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
|
}, [searchHighlights, focusedCell]);
|
|
|
|
// 🆕 Search Panel: 이전 검색 결과로 이동
|
|
const goToPrevSearchResult = useCallback(() => {
|
|
if (searchHighlights.size === 0) return;
|
|
|
|
const highlightArray = Array.from(searchHighlights).sort((a, b) => {
|
|
const [aRow, aCol] = a.split("-").map(Number);
|
|
const [bRow, bCol] = b.split("-").map(Number);
|
|
if (aRow !== bRow) return aRow - bRow;
|
|
return aCol - bCol;
|
|
});
|
|
|
|
if (!focusedCell) {
|
|
const lastIdx = highlightArray.length - 1;
|
|
const [rowIdx, colIdx] = highlightArray[lastIdx].split("-").map(Number);
|
|
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
|
return;
|
|
}
|
|
|
|
const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`;
|
|
const currentIndex = highlightArray.indexOf(currentKey);
|
|
const prevIndex = currentIndex <= 0 ? highlightArray.length - 1 : currentIndex - 1;
|
|
const [rowIdx, colIdx] = highlightArray[prevIndex].split("-").map(Number);
|
|
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
|
}, [searchHighlights, focusedCell]);
|
|
|
|
// 🆕 Search Panel: 검색 초기화
|
|
const clearGlobalSearch = useCallback(() => {
|
|
setGlobalSearchTerm("");
|
|
setSearchHighlights(new Set());
|
|
setIsSearchPanelOpen(false);
|
|
}, []);
|
|
|
|
// 🆕 Filter Builder: 조건 추가
|
|
const addFilterCondition = useCallback((groupId: string, defaultColumn?: string) => {
|
|
setFilterGroups((prev) =>
|
|
prev.map((group) =>
|
|
group.id === groupId
|
|
? {
|
|
...group,
|
|
conditions: [
|
|
...group.conditions,
|
|
{
|
|
id: `cond-${Date.now()}`,
|
|
column: defaultColumn || "",
|
|
operator: "contains" as const,
|
|
value: "",
|
|
},
|
|
],
|
|
}
|
|
: group,
|
|
),
|
|
);
|
|
}, []);
|
|
|
|
// 🆕 Filter Builder: 조건 삭제
|
|
const removeFilterCondition = useCallback((groupId: string, conditionId: string) => {
|
|
setFilterGroups((prev) =>
|
|
prev.map((group) =>
|
|
group.id === groupId
|
|
? {
|
|
...group,
|
|
conditions: group.conditions.filter((c) => c.id !== conditionId),
|
|
}
|
|
: group,
|
|
),
|
|
);
|
|
}, []);
|
|
|
|
// 🆕 Filter Builder: 조건 업데이트
|
|
const updateFilterCondition = useCallback(
|
|
(groupId: string, conditionId: string, field: keyof FilterCondition, value: string) => {
|
|
setFilterGroups((prev) =>
|
|
prev.map((group) =>
|
|
group.id === groupId
|
|
? {
|
|
...group,
|
|
conditions: group.conditions.map((c) => (c.id === conditionId ? { ...c, [field]: value } : c)),
|
|
}
|
|
: group,
|
|
),
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 🆕 Filter Builder: 그룹 추가
|
|
const addFilterGroup = useCallback((defaultColumn?: string) => {
|
|
setFilterGroups((prev) => [
|
|
...prev,
|
|
{
|
|
id: `group-${Date.now()}`,
|
|
logic: "AND" as const,
|
|
conditions: [
|
|
{
|
|
id: `cond-${Date.now()}`,
|
|
column: defaultColumn || "",
|
|
operator: "contains" as const,
|
|
value: "",
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
}, []);
|
|
|
|
// 🆕 Filter Builder: 그룹 삭제
|
|
const removeFilterGroup = useCallback((groupId: string) => {
|
|
setFilterGroups((prev) => prev.filter((g) => g.id !== groupId));
|
|
}, []);
|
|
|
|
// 🆕 Filter Builder: 그룹 로직 변경
|
|
const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => {
|
|
setFilterGroups((prev) => prev.map((group) => (group.id === groupId ? { ...group, logic } : group)));
|
|
}, []);
|
|
|
|
// 🆕 Filter Builder: 필터 적용
|
|
const applyFilterBuilder = useCallback(() => {
|
|
// 유효한 조건 개수 계산
|
|
let validConditions = 0;
|
|
filterGroups.forEach((group) => {
|
|
group.conditions.forEach((cond) => {
|
|
if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) {
|
|
validConditions++;
|
|
}
|
|
});
|
|
});
|
|
setActiveFilterCount(validConditions);
|
|
setIsFilterBuilderOpen(false);
|
|
toast.success(`${validConditions}개 필터 조건 적용됨`);
|
|
}, [filterGroups]);
|
|
|
|
// 🆕 Filter Builder: 필터 초기화
|
|
const clearFilterBuilder = useCallback(() => {
|
|
setFilterGroups([]);
|
|
setActiveFilterCount(0);
|
|
toast.info("필터 초기화됨");
|
|
}, []);
|
|
|
|
// 🆕 Filter Builder: 조건 평가 함수
|
|
const evaluateCondition = useCallback((value: any, condition: FilterCondition): boolean => {
|
|
const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : "";
|
|
const condValue = condition.value.toLowerCase();
|
|
|
|
switch (condition.operator) {
|
|
case "equals":
|
|
return strValue === condValue;
|
|
case "notEquals":
|
|
return strValue !== condValue;
|
|
case "contains":
|
|
return strValue.includes(condValue);
|
|
case "notContains":
|
|
return !strValue.includes(condValue);
|
|
case "startsWith":
|
|
return strValue.startsWith(condValue);
|
|
case "endsWith":
|
|
return strValue.endsWith(condValue);
|
|
case "greaterThan":
|
|
return parseFloat(strValue) > parseFloat(condValue);
|
|
case "lessThan":
|
|
return parseFloat(strValue) < parseFloat(condValue);
|
|
case "greaterOrEqual":
|
|
return parseFloat(strValue) >= parseFloat(condValue);
|
|
case "lessOrEqual":
|
|
return parseFloat(strValue) <= parseFloat(condValue);
|
|
case "isEmpty":
|
|
return strValue === "" || value === null || value === undefined;
|
|
case "isNotEmpty":
|
|
return strValue !== "" && value !== null && value !== undefined;
|
|
default:
|
|
return true;
|
|
}
|
|
}, []);
|
|
|
|
// 🆕 Filter Builder: 행이 필터 조건을 만족하는지 확인
|
|
const rowPassesFilterBuilder = useCallback(
|
|
(row: any): boolean => {
|
|
if (filterGroups.length === 0) return true;
|
|
|
|
// 모든 그룹이 AND로 연결됨 (그룹 간)
|
|
return filterGroups.every((group) => {
|
|
const validConditions = group.conditions.filter(
|
|
(c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value),
|
|
);
|
|
if (validConditions.length === 0) return true;
|
|
|
|
if (group.logic === "AND") {
|
|
return validConditions.every((cond) => evaluateCondition(row[cond.column], cond));
|
|
} else {
|
|
return validConditions.some((cond) => evaluateCondition(row[cond.column], cond));
|
|
}
|
|
});
|
|
},
|
|
[filterGroups, evaluateCondition],
|
|
);
|
|
|
|
// 🆕 컬럼 드래그 시작
|
|
const handleColumnDragStart = useCallback(
|
|
(e: React.DragEvent<HTMLTableCellElement>, index: number) => {
|
|
if (!isColumnDragEnabled) return;
|
|
|
|
setDraggedColumnIndex(index);
|
|
e.dataTransfer.effectAllowed = "move";
|
|
e.dataTransfer.setData("text/plain", `col-${index}`);
|
|
},
|
|
[isColumnDragEnabled],
|
|
);
|
|
|
|
// 🆕 컬럼 드래그 오버
|
|
const handleColumnDragOver = useCallback(
|
|
(e: React.DragEvent<HTMLTableCellElement>, index: number) => {
|
|
if (!isColumnDragEnabled || draggedColumnIndex === null) return;
|
|
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
|
|
if (index !== draggedColumnIndex) {
|
|
setDropTargetColumnIndex(index);
|
|
}
|
|
},
|
|
[isColumnDragEnabled, draggedColumnIndex],
|
|
);
|
|
|
|
// 🆕 컬럼 드래그 종료
|
|
const handleColumnDragEnd = useCallback(() => {
|
|
setDraggedColumnIndex(null);
|
|
setDropTargetColumnIndex(null);
|
|
}, []);
|
|
|
|
// 🆕 컬럼 드롭
|
|
const handleColumnDrop = useCallback(
|
|
(e: React.DragEvent<HTMLTableCellElement>, targetIndex: number) => {
|
|
e.preventDefault();
|
|
|
|
if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) {
|
|
handleColumnDragEnd();
|
|
return;
|
|
}
|
|
|
|
// 컬럼 순서 변경
|
|
const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))];
|
|
const [movedColumn] = newOrder.splice(draggedColumnIndex, 1);
|
|
newOrder.splice(targetIndex, 0, movedColumn);
|
|
|
|
setColumnOrder(newOrder);
|
|
toast.info("컬럼 순서가 변경되었습니다.");
|
|
|
|
handleColumnDragEnd();
|
|
},
|
|
[isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd],
|
|
);
|
|
|
|
// 🆕 행 드래그 시작
|
|
const handleRowDragStart = useCallback(
|
|
(e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
|
if (!isDragEnabled) return;
|
|
|
|
setDraggedRowIndex(index);
|
|
e.dataTransfer.effectAllowed = "move";
|
|
e.dataTransfer.setData("text/plain", String(index));
|
|
|
|
// 드래그 이미지 설정 (반투명)
|
|
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
|
|
dragImage.style.opacity = "0.5";
|
|
dragImage.style.position = "absolute";
|
|
dragImage.style.top = "-1000px";
|
|
document.body.appendChild(dragImage);
|
|
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
|
setTimeout(() => document.body.removeChild(dragImage), 0);
|
|
},
|
|
[isDragEnabled],
|
|
);
|
|
|
|
// 🆕 행 드래그 오버
|
|
const handleRowDragOver = useCallback(
|
|
(e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
|
if (!isDragEnabled || draggedRowIndex === null) return;
|
|
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
|
|
if (index !== draggedRowIndex) {
|
|
setDropTargetIndex(index);
|
|
}
|
|
},
|
|
[isDragEnabled, draggedRowIndex],
|
|
);
|
|
|
|
// 🆕 행 드래그 종료
|
|
const handleRowDragEnd = useCallback(() => {
|
|
setDraggedRowIndex(null);
|
|
setDropTargetIndex(null);
|
|
}, []);
|
|
|
|
// 🆕 행 드롭
|
|
const handleRowDrop = useCallback(
|
|
async (e: React.DragEvent<HTMLTableRowElement>, targetIndex: number) => {
|
|
e.preventDefault();
|
|
|
|
if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) {
|
|
handleRowDragEnd();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 로컬 데이터 재정렬
|
|
const newData = [...filteredData];
|
|
const [movedRow] = newData.splice(draggedRowIndex, 1);
|
|
newData.splice(targetIndex, 0, movedRow);
|
|
|
|
// 서버에 순서 저장 (order_index 필드가 있는 경우)
|
|
const orderField = (tableConfig as any).orderField || "order_index";
|
|
const hasOrderField = newData[0] && orderField in newData[0];
|
|
|
|
if (hasOrderField && tableConfig.selectedTable) {
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const primaryKeyField = tableConfig.primaryKey || "id";
|
|
|
|
// 영향받는 행들의 순서 업데이트
|
|
const updates = newData.map((row, idx) => ({
|
|
tableName: tableConfig.selectedTable,
|
|
keyField: primaryKeyField,
|
|
keyValue: row[primaryKeyField],
|
|
updateField: orderField,
|
|
updateValue: idx + 1,
|
|
}));
|
|
|
|
// 배치 업데이트
|
|
await Promise.all(updates.map((update) => apiClient.put("/dynamic-form/update-field", update)));
|
|
|
|
toast.success("순서가 변경되었습니다.");
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
} else {
|
|
// 로컬에서만 순서 변경 (저장 안함)
|
|
toast.info("순서가 변경되었습니다. (로컬만)");
|
|
}
|
|
} catch (error) {
|
|
toast.error("순서 변경 중 오류가 발생했습니다.");
|
|
}
|
|
|
|
handleRowDragEnd();
|
|
},
|
|
[isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd],
|
|
);
|
|
|
|
// 🆕 PDF 내보내기 (인쇄용 HTML 생성)
|
|
const exportToPdf = useCallback(
|
|
(exportAll: boolean = true) => {
|
|
try {
|
|
// 내보낼 데이터 선택
|
|
let exportData: any[];
|
|
if (exportAll) {
|
|
exportData = filteredData;
|
|
} else {
|
|
exportData = filteredData.filter((row, index) => {
|
|
const rowKey = getRowKey(row, index);
|
|
return selectedRows.has(rowKey);
|
|
});
|
|
}
|
|
|
|
if (exportData.length === 0) {
|
|
toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
// 컬럼 정보 가져오기 (체크박스 제외)
|
|
const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
|
|
|
|
// 인쇄용 HTML 생성
|
|
const printContent = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>${tableLabel || tableConfig.selectedTable || "데이터"}</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; padding: 20px; }
|
|
h1 { font-size: 18px; margin-bottom: 10px; text-align: center; }
|
|
.info { font-size: 12px; color: #666; margin-bottom: 20px; text-align: center; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
|
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
|
|
th { background-color: #f5f5f5; font-weight: 600; }
|
|
tr:nth-child(even) { background-color: #fafafa; }
|
|
.number { text-align: right; }
|
|
@media print {
|
|
body { padding: 0; }
|
|
table { page-break-inside: auto; }
|
|
tr { page-break-inside: avoid; page-break-after: auto; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>${tableLabel || tableConfig.selectedTable || "데이터 목록"}</h1>
|
|
<div class="info">
|
|
출력일: ${new Date().toLocaleDateString("ko-KR")} |
|
|
총 ${exportData.length}건
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
${exportColumns.map((col) => `<th>${columnLabels[col.columnName] || col.columnName}</th>`).join("")}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${exportData
|
|
.map(
|
|
(row) => `
|
|
<tr>
|
|
${exportColumns
|
|
.map((col) => {
|
|
const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName;
|
|
let value = row[mappedColumnName];
|
|
|
|
// 카테고리 매핑
|
|
if (categoryMappings[col.columnName] && value !== null && value !== undefined) {
|
|
const mapping = categoryMappings[col.columnName][String(value)];
|
|
if (mapping) value = mapping.label;
|
|
}
|
|
|
|
const meta = columnMeta[col.columnName];
|
|
const inputType = meta?.inputType || (col as any).inputType;
|
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
|
|
|
return `<td class="${isNumeric ? "number" : ""}">${value ?? ""}</td>`;
|
|
})
|
|
.join("")}
|
|
</tr>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</tbody>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
// 새 창에서 인쇄
|
|
const printWindow = window.open("", "_blank");
|
|
if (printWindow) {
|
|
printWindow.document.write(printContent);
|
|
printWindow.document.close();
|
|
printWindow.onload = () => {
|
|
printWindow.print();
|
|
};
|
|
toast.success("인쇄 창이 열렸습니다.");
|
|
} else {
|
|
toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요.");
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ PDF 내보내기 실패:", error);
|
|
showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
},
|
|
[
|
|
filteredData,
|
|
selectedRows,
|
|
visibleColumns,
|
|
columnLabels,
|
|
joinColumnMapping,
|
|
categoryMappings,
|
|
columnMeta,
|
|
tableLabel,
|
|
tableConfig.selectedTable,
|
|
getRowKey,
|
|
],
|
|
);
|
|
|
|
// 🆕 편집 중 키보드 핸들러 (간단 버전 - Tab 이동은 visibleColumns 정의 후 처리)
|
|
const handleEditKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
switch (e.key) {
|
|
case "Enter":
|
|
e.preventDefault();
|
|
saveEditing();
|
|
break;
|
|
case "Escape":
|
|
e.preventDefault();
|
|
cancelEditing();
|
|
break;
|
|
case "Tab":
|
|
e.preventDefault();
|
|
saveEditing();
|
|
// Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리
|
|
break;
|
|
}
|
|
},
|
|
[saveEditing, cancelEditing],
|
|
);
|
|
|
|
// 🆕 편집 입력 필드가 나타나면 자동 포커스
|
|
useEffect(() => {
|
|
if (editingCell && editInputRef.current) {
|
|
editInputRef.current.focus();
|
|
// select()는 input 요소에서만 사용 가능 (select 요소에서는 사용 불가)
|
|
if (typeof editInputRef.current.select === "function") {
|
|
editInputRef.current.select();
|
|
}
|
|
}
|
|
}, [editingCell]);
|
|
|
|
// 🆕 포커스된 셀로 스크롤
|
|
useEffect(() => {
|
|
if (focusedCell && tableContainerRef.current) {
|
|
const focusedCellElement = tableContainerRef.current.querySelector(
|
|
`[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]`,
|
|
) as HTMLElement;
|
|
|
|
if (focusedCellElement) {
|
|
focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}
|
|
}
|
|
}, [focusedCell]);
|
|
|
|
// 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능)
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onClick?.();
|
|
};
|
|
|
|
// ========================================
|
|
// 컬럼 관련 (visibleColumns는 상단에서 정의됨)
|
|
// ========================================
|
|
|
|
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
|
|
const lastColumnOrderRef = useRef<string>("");
|
|
|
|
useEffect(() => {
|
|
// console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
|
|
// hasCallback: !!onSelectedRowsChange,
|
|
// visibleColumnsLength: visibleColumns.length,
|
|
// visibleColumnsNames: visibleColumns.map((c) => c.columnName),
|
|
// });
|
|
|
|
if (!onSelectedRowsChange) {
|
|
// console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
|
return;
|
|
}
|
|
|
|
if (visibleColumns.length === 0) {
|
|
// console.warn("⚠️ visibleColumns가 비어있습니다!");
|
|
return;
|
|
}
|
|
|
|
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
|
|
|
|
// console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
|
|
|
|
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
|
|
const columnOrderString = currentColumnOrder.join(",");
|
|
// console.log("🔍 [컬럼 순서] 비교:", {
|
|
// current: columnOrderString,
|
|
// last: lastColumnOrderRef.current,
|
|
// isDifferent: columnOrderString !== lastColumnOrderRef.current,
|
|
// });
|
|
|
|
if (columnOrderString === lastColumnOrderRef.current) {
|
|
// console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
|
|
return;
|
|
}
|
|
|
|
lastColumnOrderRef.current = columnOrderString;
|
|
// console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
|
|
|
|
// 선택된 행 데이터 가져오기
|
|
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
|
|
|
// 화면에 표시된 데이터를 컬럼 순서대로 재정렬
|
|
const reorderedData = data.map((row: any) => {
|
|
const reordered: any = {};
|
|
visibleColumns.forEach((col) => {
|
|
if (col.columnName in row) {
|
|
reordered[col.columnName] = row[col.columnName];
|
|
}
|
|
});
|
|
// 나머지 컬럼 추가
|
|
Object.keys(row).forEach((key) => {
|
|
if (!(key in reordered)) {
|
|
reordered[key] = row[key];
|
|
}
|
|
});
|
|
return reordered;
|
|
});
|
|
|
|
onSelectedRowsChange(
|
|
Array.from(selectedRows),
|
|
selectedRowsData,
|
|
sortColumn,
|
|
sortDirection,
|
|
currentColumnOrder,
|
|
reorderedData,
|
|
);
|
|
}, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화
|
|
|
|
// 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치)
|
|
const handleTableKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
// 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리)
|
|
if (editingCell) return;
|
|
|
|
if (!focusedCell || data.length === 0) return;
|
|
|
|
const { rowIndex, colIndex } = focusedCell;
|
|
const maxRowIndex = data.length - 1;
|
|
const maxColIndex = visibleColumns.length - 1;
|
|
|
|
switch (e.key) {
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
if (rowIndex > 0) {
|
|
setFocusedCell({ rowIndex: rowIndex - 1, colIndex });
|
|
}
|
|
break;
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
if (rowIndex < maxRowIndex) {
|
|
setFocusedCell({ rowIndex: rowIndex + 1, colIndex });
|
|
}
|
|
break;
|
|
case "ArrowLeft":
|
|
e.preventDefault();
|
|
if (colIndex > 0) {
|
|
setFocusedCell({ rowIndex, colIndex: colIndex - 1 });
|
|
}
|
|
break;
|
|
case "ArrowRight":
|
|
e.preventDefault();
|
|
if (colIndex < maxColIndex) {
|
|
setFocusedCell({ rowIndex, colIndex: colIndex + 1 });
|
|
}
|
|
break;
|
|
case "Enter":
|
|
e.preventDefault();
|
|
// 현재 행 선택/해제
|
|
const enterRow = data[rowIndex];
|
|
if (enterRow) {
|
|
const rowKey = getRowKey(enterRow, rowIndex);
|
|
const isCurrentlySelected = selectedRows.has(rowKey);
|
|
handleRowSelection(rowKey, !isCurrentlySelected);
|
|
}
|
|
break;
|
|
case " ": // Space
|
|
e.preventDefault();
|
|
// 체크박스 토글
|
|
const spaceRow = data[rowIndex];
|
|
if (spaceRow) {
|
|
const currentRowKey = getRowKey(spaceRow, rowIndex);
|
|
const isChecked = selectedRows.has(currentRowKey);
|
|
handleRowSelection(currentRowKey, !isChecked);
|
|
}
|
|
break;
|
|
case "F2":
|
|
// 🆕 F2: 편집 모드 진입
|
|
e.preventDefault();
|
|
{
|
|
const col = visibleColumns[colIndex];
|
|
if (col && col.columnName !== "__checkbox__") {
|
|
// 🆕 편집 불가 컬럼 체크
|
|
if (col.editable === false) {
|
|
toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`);
|
|
break;
|
|
}
|
|
const row = data[rowIndex];
|
|
const mappedCol = joinColumnMapping[col.columnName] || col.columnName;
|
|
const val = row?.[mappedCol];
|
|
setEditingCell({
|
|
rowIndex,
|
|
colIndex,
|
|
columnName: col.columnName,
|
|
originalValue: val,
|
|
});
|
|
setEditingValue(val !== null && val !== undefined ? String(val) : "");
|
|
}
|
|
}
|
|
break;
|
|
case "b":
|
|
case "B":
|
|
// 🆕 Ctrl+B: 배치 편집 모드 토글
|
|
if (e.ctrlKey) {
|
|
e.preventDefault();
|
|
setEditMode((prev) => {
|
|
const newMode = prev === "immediate" ? "batch" : "immediate";
|
|
if (newMode === "immediate" && pendingChanges.size > 0) {
|
|
// 즉시 모드로 전환 시 저장되지 않은 변경사항 경고
|
|
const confirmDiscard = window.confirm(
|
|
`저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?`,
|
|
);
|
|
if (confirmDiscard) {
|
|
setPendingChanges(new Map());
|
|
setLocalEditedData({});
|
|
toast.info("배치 편집 모드 종료");
|
|
return "immediate";
|
|
}
|
|
return "batch";
|
|
}
|
|
toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드");
|
|
return newMode;
|
|
});
|
|
}
|
|
break;
|
|
case "s":
|
|
case "S":
|
|
// 🆕 Ctrl+S: 배치 저장
|
|
if (e.ctrlKey && editMode === "batch") {
|
|
e.preventDefault();
|
|
saveBatchChanges();
|
|
}
|
|
break;
|
|
case "c":
|
|
case "C":
|
|
// 🆕 Ctrl+C: 선택된 행/셀 복사
|
|
if (e.ctrlKey) {
|
|
e.preventDefault();
|
|
handleCopy();
|
|
}
|
|
break;
|
|
case "v":
|
|
case "V":
|
|
// 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만)
|
|
if (e.ctrlKey && editingCell) {
|
|
// 기본 동작 허용 (input에서 처리)
|
|
}
|
|
break;
|
|
case "a":
|
|
case "A":
|
|
// 🆕 Ctrl+A: 전체 선택
|
|
if (e.ctrlKey) {
|
|
e.preventDefault();
|
|
handleSelectAllRows();
|
|
}
|
|
break;
|
|
case "f":
|
|
case "F":
|
|
// 🆕 Ctrl+F: 통합 검색 패널 열기
|
|
if (e.ctrlKey) {
|
|
e.preventDefault();
|
|
setIsSearchPanelOpen(true);
|
|
}
|
|
break;
|
|
case "F3":
|
|
// 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
goToPrevSearchResult();
|
|
} else {
|
|
goToNextSearchResult();
|
|
}
|
|
break;
|
|
case "Home":
|
|
e.preventDefault();
|
|
if (e.ctrlKey) {
|
|
// Ctrl+Home: 첫 번째 셀로
|
|
setFocusedCell({ rowIndex: 0, colIndex: 0 });
|
|
} else {
|
|
// Home: 현재 행의 첫 번째 셀로
|
|
setFocusedCell({ rowIndex, colIndex: 0 });
|
|
}
|
|
break;
|
|
case "End":
|
|
e.preventDefault();
|
|
if (e.ctrlKey) {
|
|
// Ctrl+End: 마지막 셀로
|
|
setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex });
|
|
} else {
|
|
// End: 현재 행의 마지막 셀로
|
|
setFocusedCell({ rowIndex, colIndex: maxColIndex });
|
|
}
|
|
break;
|
|
case "PageUp":
|
|
e.preventDefault();
|
|
// 10행 위로
|
|
setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex });
|
|
break;
|
|
case "PageDown":
|
|
e.preventDefault();
|
|
// 10행 아래로
|
|
setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex });
|
|
break;
|
|
case "Escape":
|
|
e.preventDefault();
|
|
// 포커스 해제
|
|
setFocusedCell(null);
|
|
break;
|
|
case "Tab":
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
// Shift+Tab: 이전 셀
|
|
if (colIndex > 0) {
|
|
setFocusedCell({ rowIndex, colIndex: colIndex - 1 });
|
|
} else if (rowIndex > 0) {
|
|
setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex });
|
|
}
|
|
} else {
|
|
// Tab: 다음 셀
|
|
if (colIndex < maxColIndex) {
|
|
setFocusedCell({ rowIndex, colIndex: colIndex + 1 });
|
|
} else if (rowIndex < maxRowIndex) {
|
|
setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 });
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
// 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등)
|
|
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
const column = visibleColumns[colIndex];
|
|
if (column && column.columnName !== "__checkbox__") {
|
|
// 🆕 편집 불가 컬럼 체크
|
|
if (column.editable === false) {
|
|
toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`);
|
|
break;
|
|
}
|
|
e.preventDefault();
|
|
// 편집 시작 (현재 키를 초기값으로)
|
|
const row = data[rowIndex];
|
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
|
const value = row?.[mappedColumnName];
|
|
|
|
setEditingCell({
|
|
rowIndex,
|
|
colIndex,
|
|
columnName: column.columnName,
|
|
originalValue: value,
|
|
});
|
|
setEditingValue(e.key); // 입력한 키로 시작
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
},
|
|
[editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection],
|
|
);
|
|
|
|
const getColumnWidth = (column: ColumnConfig) => {
|
|
if (column.columnName === "__checkbox__") return 50;
|
|
if (column.width) return column.width;
|
|
|
|
switch (column.format) {
|
|
case "date":
|
|
return 120;
|
|
case "number":
|
|
case "currency":
|
|
return 100;
|
|
case "boolean":
|
|
return 80;
|
|
default:
|
|
return 150;
|
|
}
|
|
};
|
|
|
|
const renderCheckboxHeader = () => {
|
|
if (!tableConfig.checkbox?.selectAll) return null;
|
|
|
|
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
|
|
};
|
|
|
|
const renderCheckboxCell = (row: any, index: number) => {
|
|
const rowKey = getRowKey(row, index);
|
|
const isChecked = selectedRows.has(rowKey);
|
|
|
|
return (
|
|
<Checkbox
|
|
checked={isChecked}
|
|
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
|
|
aria-label={`행 ${index + 1} 선택`}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const formatCellValue = useCallback(
|
|
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
|
// 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능
|
|
// 이 체크를 가장 먼저 수행 (null 체크보다 앞에)
|
|
if (column.entityDisplayConfig && rowData) {
|
|
const displayColumns =
|
|
column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns;
|
|
const separator = column.entityDisplayConfig.separator;
|
|
|
|
if (displayColumns && displayColumns.length > 0) {
|
|
// 선택된 컬럼들의 값을 구분자로 조합
|
|
const values = displayColumns
|
|
.map((colName: string) => {
|
|
// 🎯 백엔드 alias 규칙: ${sourceColumn}_${displayColumn}
|
|
// 예: manager 컬럼에서 user_name 선택 시 → manager_user_name
|
|
const joinedKey = `${column.columnName}_${colName}`;
|
|
let cellValue = rowData[joinedKey];
|
|
|
|
// fallback: 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
|
|
if (cellValue === null || cellValue === undefined) {
|
|
cellValue = rowData[colName];
|
|
}
|
|
|
|
if (cellValue === null || cellValue === undefined) return "";
|
|
return String(cellValue);
|
|
})
|
|
.filter((v: string) => v !== ""); // 빈 값 제외
|
|
|
|
const result = values.join(separator || " - ");
|
|
if (result) {
|
|
return result; // 결과가 있으면 반환
|
|
}
|
|
// 결과가 비어있으면 아래로 계속 진행 (원래 값 사용)
|
|
}
|
|
}
|
|
|
|
// value가 null/undefined면 "-" 반환
|
|
if (value === null || value === undefined) return "-";
|
|
|
|
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
|
|
if (column.columnName === "writer" && rowData && rowData.writer_name) {
|
|
return rowData.writer_name;
|
|
}
|
|
|
|
// 🆕 메인 테이블 메타 또는 조인 테이블 메타에서 정보 가져오기
|
|
const meta = columnMeta[column.columnName] || joinedColumnMeta[column.columnName];
|
|
|
|
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
|
const inputType = meta?.inputType || column.inputType;
|
|
|
|
// 🖼️ 이미지 타입: 작은 썸네일 표시 (TableCellImage 컴포넌트 사용)
|
|
if (inputType === "image" && value) {
|
|
return <TableCellImage value={String(value)} />;
|
|
}
|
|
|
|
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
|
|
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
|
|
const isAttachmentColumn =
|
|
inputType === "file" ||
|
|
inputType === "attachment" ||
|
|
column.columnName === "attachments" ||
|
|
column.columnName?.toLowerCase().includes("attachment") ||
|
|
column.columnName?.toLowerCase().includes("file");
|
|
|
|
if (isAttachmentColumn) {
|
|
// JSONB 배열 또는 JSON 문자열 파싱
|
|
let files: any[] = [];
|
|
try {
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = JSON.parse(value);
|
|
files = Array.isArray(parsed) ? parsed : [];
|
|
} else if (Array.isArray(value)) {
|
|
files = value;
|
|
} else if (value && typeof value === "object") {
|
|
// 단일 객체인 경우 배열로 변환
|
|
files = [value];
|
|
}
|
|
} catch (e) {
|
|
// 파싱 실패 시 빈 배열
|
|
console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e });
|
|
}
|
|
|
|
if (!files || files.length === 0) {
|
|
return <span className="text-muted-foreground text-xs">-</span>;
|
|
}
|
|
|
|
// 파일 이름 표시 (여러 개면 쉼표로 구분)
|
|
const { Paperclip } = require("lucide-react");
|
|
const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", ");
|
|
|
|
return (
|
|
<div className="flex max-w-full items-center gap-1.5 text-sm">
|
|
<Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
|
|
<span className="truncate text-blue-600" title={fileNames}>
|
|
{fileNames}
|
|
</span>
|
|
{files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
|
if (inputType === "category") {
|
|
if (!value) return "";
|
|
|
|
// 🆕 엔티티 조인 컬럼의 경우 여러 형태로 매핑 찾기
|
|
// 1. 원래 컬럼명 (item_info.material)
|
|
// 2. 점(.) 뒤의 컬럼명만 (material)
|
|
let mapping = categoryMappings[column.columnName];
|
|
|
|
if (!mapping && column.columnName.includes(".")) {
|
|
const simpleColumnName = column.columnName.split(".").pop();
|
|
if (simpleColumnName) {
|
|
mapping = categoryMappings[simpleColumnName];
|
|
}
|
|
}
|
|
|
|
const { Badge } = require("@/components/ui/badge");
|
|
|
|
// 다중 값 처리: 콤마로 구분된 값들을 분리
|
|
const valueStr = String(value);
|
|
const values = valueStr.includes(",")
|
|
? valueStr
|
|
.split(",")
|
|
.map((v) => v.trim())
|
|
.filter((v) => v)
|
|
: [valueStr];
|
|
|
|
// 단일 값인 경우 (기존 로직)
|
|
if (values.length === 1) {
|
|
const categoryData = mapping?.[values[0]];
|
|
const displayLabel = categoryData?.label || values[0];
|
|
const displayColor = categoryData?.color;
|
|
|
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
|
return <span className="text-sm">{displayLabel}</span>;
|
|
}
|
|
|
|
return (
|
|
<Badge
|
|
style={{
|
|
backgroundColor: displayColor,
|
|
borderColor: displayColor,
|
|
}}
|
|
className="text-white"
|
|
>
|
|
{displayLabel}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
// 다중 값인 경우: 여러 배지 렌더링
|
|
return (
|
|
<div className="flex flex-nowrap gap-1 overflow-hidden">
|
|
{values.map((val, idx) => {
|
|
const categoryData = mapping?.[val];
|
|
const displayLabel = categoryData?.label || val;
|
|
const displayColor = categoryData?.color;
|
|
|
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
|
return (
|
|
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
|
|
{displayLabel}
|
|
{idx < values.length - 1 && ", "}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Badge
|
|
key={idx}
|
|
style={{
|
|
backgroundColor: displayColor,
|
|
borderColor: displayColor,
|
|
}}
|
|
className="shrink-0 whitespace-nowrap text-white"
|
|
>
|
|
{displayLabel}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 코드 타입: 코드 값 → 코드명 변환
|
|
if (inputType === "code" && meta?.codeCategory && value) {
|
|
try {
|
|
// optimizedConvertCode(categoryCode, codeValue) 순서 주의!
|
|
const convertedValue = optimizedConvertCode(meta.codeCategory, value);
|
|
// 변환에 성공했으면 변환된 코드명 반환
|
|
if (convertedValue && convertedValue !== value) {
|
|
return convertedValue;
|
|
}
|
|
} catch (error) {
|
|
console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error);
|
|
}
|
|
// 변환 실패 시 원본 코드 값 반환
|
|
return String(value);
|
|
}
|
|
|
|
// 날짜 타입 포맷팅 (yyyy-mm-dd)
|
|
if (inputType === "date" || inputType === "datetime") {
|
|
if (value) {
|
|
try {
|
|
const date = new Date(value);
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
return "-";
|
|
}
|
|
|
|
// 숫자 타입 포맷팅 (천단위 구분자 설정 확인)
|
|
if (inputType === "number" || inputType === "decimal") {
|
|
if (value !== null && value !== undefined && value !== "") {
|
|
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
|
if (!isNaN(numValue)) {
|
|
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
|
if (column.thousandSeparator !== false) {
|
|
return numValue.toLocaleString("ko-KR");
|
|
}
|
|
return String(numValue);
|
|
}
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
switch (column.format) {
|
|
case "number":
|
|
if (value !== null && value !== undefined && value !== "") {
|
|
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
|
if (!isNaN(numValue)) {
|
|
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
|
if (column.thousandSeparator !== false) {
|
|
return numValue.toLocaleString("ko-KR");
|
|
}
|
|
return String(numValue);
|
|
}
|
|
}
|
|
return String(value);
|
|
case "date":
|
|
if (value) {
|
|
try {
|
|
const date = new Date(value);
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
return "-";
|
|
case "currency":
|
|
if (typeof value === "number") {
|
|
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
|
if (column.thousandSeparator !== false) {
|
|
return `₩${value.toLocaleString()}`;
|
|
}
|
|
return `₩${value}`;
|
|
}
|
|
return value;
|
|
case "boolean":
|
|
return value ? "예" : "아니오";
|
|
default:
|
|
return String(value);
|
|
}
|
|
},
|
|
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings],
|
|
);
|
|
|
|
// ========================================
|
|
// useEffect 훅
|
|
// ========================================
|
|
|
|
// 필터 설정 localStorage 키 생성 (화면별로 독립적)
|
|
const filterSettingKey = useMemo(() => {
|
|
if (!tableConfig.selectedTable) return null;
|
|
return screenId
|
|
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
|
: `tableList_filterSettings_${tableConfig.selectedTable}`;
|
|
}, [tableConfig.selectedTable, screenId]);
|
|
|
|
// 그룹 설정 localStorage 키 생성 (화면별로 독립적)
|
|
const groupSettingKey = useMemo(() => {
|
|
if (!tableConfig.selectedTable) return null;
|
|
return screenId
|
|
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
|
: `tableList_groupSettings_${tableConfig.selectedTable}`;
|
|
}, [tableConfig.selectedTable, screenId]);
|
|
|
|
// 저장된 필터 설정 불러오기
|
|
useEffect(() => {
|
|
if (!filterSettingKey || visibleColumns.length === 0) return;
|
|
|
|
try {
|
|
const saved = localStorage.getItem(filterSettingKey);
|
|
if (saved) {
|
|
const savedFilters = JSON.parse(saved);
|
|
setVisibleFilterColumns(new Set(savedFilters));
|
|
} else {
|
|
// 초기값: 빈 Set (아무것도 선택 안 함)
|
|
setVisibleFilterColumns(new Set());
|
|
}
|
|
} catch (error) {
|
|
console.error("필터 설정 불러오기 실패:", error);
|
|
setVisibleFilterColumns(new Set());
|
|
}
|
|
}, [filterSettingKey, visibleColumns]);
|
|
|
|
// 필터 설정 저장
|
|
const saveFilterSettings = useCallback(() => {
|
|
if (!filterSettingKey) return;
|
|
|
|
try {
|
|
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
|
|
setIsFilterSettingOpen(false);
|
|
toast.success("검색 필터 설정이 저장되었습니다");
|
|
|
|
// 검색 값 초기화
|
|
setSearchValues({});
|
|
} catch (error) {
|
|
console.error("필터 설정 저장 실패:", error);
|
|
showErrorToast("필터 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
}, [filterSettingKey, visibleFilterColumns]);
|
|
|
|
// 필터 컬럼 토글
|
|
const toggleFilterVisibility = useCallback((columnName: string) => {
|
|
setVisibleFilterColumns((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(columnName)) {
|
|
newSet.delete(columnName);
|
|
} else {
|
|
newSet.add(columnName);
|
|
}
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
// 전체 선택/해제
|
|
const toggleAllFilters = useCallback(() => {
|
|
const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
|
|
const columnNames = filterableColumns.map((col) => col.columnName);
|
|
|
|
if (visibleFilterColumns.size === columnNames.length) {
|
|
// 전체 해제
|
|
setVisibleFilterColumns(new Set());
|
|
} else {
|
|
// 전체 선택
|
|
setVisibleFilterColumns(new Set(columnNames));
|
|
}
|
|
}, [visibleFilterColumns, visibleColumns]);
|
|
|
|
// 표시할 필터 목록 (선택된 컬럼만)
|
|
const activeFilters = useMemo(() => {
|
|
return visibleColumns
|
|
.filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName))
|
|
.map((col) => ({
|
|
columnName: col.columnName,
|
|
label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
|
type: col.format || "text",
|
|
}));
|
|
}, [visibleColumns, visibleFilterColumns, columnLabels]);
|
|
|
|
// 그룹 설정 자동 저장 (localStorage)
|
|
useEffect(() => {
|
|
if (!groupSettingKey) return;
|
|
|
|
try {
|
|
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
|
} catch (error) {
|
|
console.error("그룹 설정 저장 실패:", error);
|
|
}
|
|
}, [groupSettingKey, groupByColumns]);
|
|
|
|
// 그룹 컬럼 토글
|
|
const toggleGroupColumn = useCallback((columnName: string) => {
|
|
setGroupByColumns((prev) => {
|
|
if (prev.includes(columnName)) {
|
|
return prev.filter((col) => col !== columnName);
|
|
} else {
|
|
return [...prev, columnName];
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// 사용자 옵션 저장 핸들러
|
|
const handleTableOptionsSave = useCallback(
|
|
(config: {
|
|
columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>;
|
|
showGridLines: boolean;
|
|
viewMode: "table" | "card" | "grouped-card";
|
|
}) => {
|
|
// 컬럼 순서 업데이트
|
|
const newColumnOrder = config.columns.map((col) => col.columnName);
|
|
setColumnOrder(newColumnOrder);
|
|
|
|
// 컬럼 너비 업데이트
|
|
const newWidths: Record<string, number> = {};
|
|
config.columns.forEach((col) => {
|
|
if (col.width) {
|
|
newWidths[col.columnName] = col.width;
|
|
}
|
|
});
|
|
setColumnWidths(newWidths);
|
|
|
|
// 틀고정 컬럼 업데이트
|
|
const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName);
|
|
setFrozenColumns(newFrozenColumns);
|
|
|
|
// 그리드선 표시 업데이트
|
|
setShowGridLines(config.showGridLines);
|
|
|
|
// 보기 모드 업데이트
|
|
setViewMode(config.viewMode);
|
|
|
|
// 컬럼 표시/숨기기 업데이트
|
|
const newDisplayColumns = displayColumns.map((col) => {
|
|
const configCol = config.columns.find((c) => c.columnName === col.columnName);
|
|
if (configCol) {
|
|
return { ...col, visible: configCol.visible };
|
|
}
|
|
return col;
|
|
});
|
|
setDisplayColumns(newDisplayColumns);
|
|
|
|
toast.success("테이블 옵션이 저장되었습니다");
|
|
},
|
|
[displayColumns],
|
|
);
|
|
|
|
// 그룹 펼치기/접기 토글
|
|
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
|
setCollapsedGroups((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(groupKey)) {
|
|
newSet.delete(groupKey);
|
|
} else {
|
|
newSet.add(groupKey);
|
|
}
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
// 그룹 해제
|
|
const clearGrouping = useCallback(() => {
|
|
setGroupByColumns([]);
|
|
setCollapsedGroups(new Set());
|
|
if (groupSettingKey) {
|
|
localStorage.removeItem(groupSettingKey);
|
|
}
|
|
toast.success("그룹이 해제되었습니다");
|
|
}, [groupSettingKey]);
|
|
|
|
// 데이터 그룹화
|
|
const groupedData = useMemo((): GroupedData[] => {
|
|
if (groupByColumns.length === 0 || filteredData.length === 0) return [];
|
|
|
|
const grouped = new Map<string, any[]>();
|
|
|
|
filteredData.forEach((item) => {
|
|
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
|
const keyParts = groupByColumns.map((col) => {
|
|
// 카테고리/엔티티 타입인 경우 _name 필드 사용
|
|
const inputType = columnMeta?.[col]?.inputType;
|
|
let displayValue = item[col];
|
|
|
|
if (inputType === "category" || inputType === "entity" || inputType === "code") {
|
|
// _name 필드가 있으면 사용 (예: division_name, writer_name)
|
|
const nameField = `${col}_name`;
|
|
if (item[nameField] !== undefined && item[nameField] !== null) {
|
|
displayValue = item[nameField];
|
|
}
|
|
}
|
|
|
|
const label = columnLabels[col] || col;
|
|
return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`;
|
|
});
|
|
const groupKey = keyParts.join(" > ");
|
|
|
|
if (!grouped.has(groupKey)) {
|
|
grouped.set(groupKey, []);
|
|
}
|
|
grouped.get(groupKey)!.push(item);
|
|
});
|
|
|
|
return Array.from(grouped.entries()).map(([groupKey, items]) => {
|
|
const groupValues: Record<string, any> = {};
|
|
groupByColumns.forEach((col) => {
|
|
groupValues[col] = items[0]?.[col];
|
|
});
|
|
|
|
// 🆕 그룹별 소계 계산
|
|
const groupSummary: Record<string, { sum: number; avg: number; count: number }> = {};
|
|
|
|
// 숫자형 컬럼에 대해 소계 계산
|
|
(tableConfig.columns || []).forEach((col: { columnName: string }) => {
|
|
if (col.columnName === "__checkbox__") return;
|
|
|
|
const colMeta = columnMeta?.[col.columnName];
|
|
const inputType = colMeta?.inputType;
|
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
|
|
|
if (isNumeric) {
|
|
const values = items.map((item) => parseFloat(item[col.columnName])).filter((v) => !isNaN(v));
|
|
|
|
if (values.length > 0) {
|
|
const sum = values.reduce((a, b) => a + b, 0);
|
|
groupSummary[col.columnName] = {
|
|
sum,
|
|
avg: sum / values.length,
|
|
count: values.length,
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
groupKey,
|
|
groupValues,
|
|
items,
|
|
count: items.length,
|
|
summary: groupSummary, // 🆕 그룹별 소계
|
|
};
|
|
});
|
|
}, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]);
|
|
|
|
// 🆕 그룹별 합산된 데이터 계산 (FilterPanel에서 설정한 경우)
|
|
const summedData = useMemo(() => {
|
|
// 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환
|
|
if (!groupSumConfig?.enabled || !groupSumConfig?.groupByColumn) {
|
|
return filteredData;
|
|
}
|
|
|
|
const groupByColumn = groupSumConfig.groupByColumn;
|
|
const groupMap = new Map<string, any>();
|
|
|
|
// 조인 컬럼인지 확인하고 실제 키 추론
|
|
const getActualKey = (columnName: string, item: any): string => {
|
|
if (columnName.includes(".")) {
|
|
const [refTable, fieldName] = columnName.split(".");
|
|
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
|
const exactKey = `${inferredSourceColumn}_${fieldName}`;
|
|
if (item[exactKey] !== undefined) return exactKey;
|
|
if (fieldName === "item_name" || fieldName === "name") {
|
|
const aliasKey = `${inferredSourceColumn}_name`;
|
|
if (item[aliasKey] !== undefined) return aliasKey;
|
|
}
|
|
}
|
|
return columnName;
|
|
};
|
|
|
|
// 숫자 타입인지 확인하는 함수
|
|
const isNumericValue = (value: any): boolean => {
|
|
if (value === null || value === undefined || value === "") return false;
|
|
const num = parseFloat(String(value));
|
|
return !isNaN(num) && isFinite(num);
|
|
};
|
|
|
|
// 그룹핑 수행
|
|
filteredData.forEach((item) => {
|
|
const actualKey = getActualKey(groupByColumn, item);
|
|
const groupValue = String(item[actualKey] || item[groupByColumn] || "");
|
|
|
|
if (!groupMap.has(groupValue)) {
|
|
// 첫 번째 항목을 기준으로 초기화
|
|
groupMap.set(groupValue, { ...item, _groupCount: 1 });
|
|
} else {
|
|
const existing = groupMap.get(groupValue);
|
|
existing._groupCount += 1;
|
|
|
|
// 모든 키에 대해 숫자면 합산
|
|
Object.keys(item).forEach((key) => {
|
|
const value = item[key];
|
|
if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) {
|
|
const numValue = parseFloat(String(value));
|
|
const existingValue = parseFloat(String(existing[key] || 0));
|
|
existing[key] = existingValue + numValue;
|
|
}
|
|
});
|
|
|
|
groupMap.set(groupValue, existing);
|
|
}
|
|
});
|
|
|
|
const result = Array.from(groupMap.values());
|
|
|
|
return result;
|
|
}, [filteredData, groupSumConfig]);
|
|
|
|
// 🆕 표시할 데이터: 합산 모드면 summedData, 아니면 filteredData
|
|
const displayData = useMemo(() => {
|
|
return groupSumConfig?.enabled ? summedData : filteredData;
|
|
}, [groupSumConfig?.enabled, summedData, filteredData]);
|
|
|
|
// 🆕 Virtual Scrolling: 보이는 행 범위 계산 (displayData 정의 이후에 위치)
|
|
const virtualScrollInfo = useMemo(() => {
|
|
const dataSource = displayData;
|
|
if (!isVirtualScrollEnabled || dataSource.length === 0) {
|
|
return {
|
|
startIndex: 0,
|
|
endIndex: dataSource.length,
|
|
visibleData: dataSource,
|
|
topSpacerHeight: 0,
|
|
bottomSpacerHeight: 0,
|
|
totalHeight: dataSource.length * ROW_HEIGHT,
|
|
};
|
|
}
|
|
|
|
const containerHeight = scrollContainerRef.current?.clientHeight || 600;
|
|
const totalRows = dataSource.length;
|
|
const totalHeight = totalRows * ROW_HEIGHT;
|
|
|
|
// 현재 보이는 행 범위 계산
|
|
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
|
const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2;
|
|
const endIndex = Math.min(totalRows, startIndex + visibleRowCount);
|
|
|
|
return {
|
|
startIndex,
|
|
endIndex,
|
|
visibleData: dataSource.slice(startIndex, endIndex),
|
|
topSpacerHeight: startIndex * ROW_HEIGHT,
|
|
bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT,
|
|
totalHeight,
|
|
};
|
|
}, [isVirtualScrollEnabled, displayData, scrollTop, ROW_HEIGHT, OVERSCAN]);
|
|
|
|
// 저장된 그룹 설정 불러오기
|
|
useEffect(() => {
|
|
if (!groupSettingKey || visibleColumns.length === 0) return;
|
|
|
|
try {
|
|
const saved = localStorage.getItem(groupSettingKey);
|
|
if (saved) {
|
|
const savedGroups = JSON.parse(saved);
|
|
setGroupByColumns(savedGroups);
|
|
}
|
|
} catch (error) {
|
|
console.error("그룹 설정 불러오기 실패:", error);
|
|
}
|
|
}, [groupSettingKey, visibleColumns]);
|
|
|
|
useEffect(() => {
|
|
fetchColumnLabels();
|
|
fetchTableLabel();
|
|
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
|
|
|
|
// 🆕 우측 화면일 때만 selectedLeftData 변경에 반응하도록 변수 생성
|
|
const isRightPanel = splitPanelPosition === "right" || currentSplitPosition === "right";
|
|
const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selectedLeftData : null;
|
|
|
|
useEffect(() => {
|
|
// console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
|
|
// isDesignMode,
|
|
// tableName: tableConfig.selectedTable,
|
|
// currentPage,
|
|
// sortColumn,
|
|
// sortDirection,
|
|
// });
|
|
|
|
if (!isDesignMode && tableConfig.selectedTable) {
|
|
fetchTableDataDebounced();
|
|
}
|
|
}, [
|
|
tableConfig.selectedTable,
|
|
currentPage,
|
|
localPageSize,
|
|
sortColumn,
|
|
sortDirection,
|
|
searchTerm,
|
|
searchValues, // 필터 값 변경 시에도 데이터 새로고침
|
|
refreshKey,
|
|
refreshTrigger, // 강제 새로고침 트리거
|
|
isDesignMode,
|
|
selectedLeftDataForRightPanel, // 🆕 우측 화면일 때만 좌측 데이터 선택 변경 시 데이터 새로고침
|
|
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (tableConfig.refreshInterval && !isDesignMode) {
|
|
const interval = setInterval(() => {
|
|
fetchTableDataDebounced();
|
|
}, tableConfig.refreshInterval * 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [tableConfig.refreshInterval, isDesignMode]);
|
|
|
|
// 🆕 전역 테이블 새로고침 이벤트 리스너
|
|
useEffect(() => {
|
|
const handleRefreshTable = () => {
|
|
if (tableConfig.selectedTable && !isDesignMode) {
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
}
|
|
};
|
|
|
|
// V2 EventBus 구독 (레거시 어댑터가 window 이벤트도 브릿지)
|
|
const unsubscribe = v2EventBus.subscribe(
|
|
V2_EVENTS.TABLE_REFRESH,
|
|
(payload) => {
|
|
// 특정 테이블만 새로고침하거나 전체 새로고침
|
|
if (!payload.tableName || payload.tableName === tableConfig.selectedTable) {
|
|
handleRefreshTable();
|
|
}
|
|
},
|
|
{ componentId: component.id }
|
|
);
|
|
|
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
window.addEventListener("refreshTable", handleRefreshTable);
|
|
|
|
return () => {
|
|
unsubscribe();
|
|
window.removeEventListener("refreshTable", handleRefreshTable);
|
|
};
|
|
}, [tableConfig.selectedTable, isDesignMode, component.id]);
|
|
|
|
// 테이블명 변경 시 전역 레지스트리에서 확인
|
|
useEffect(() => {
|
|
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) {
|
|
const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable);
|
|
if (isTarget) {
|
|
setIsRelatedButtonTarget(true);
|
|
}
|
|
}
|
|
}, [tableConfig.selectedTable]);
|
|
|
|
// RelatedDataButtons 등록/해제 이벤트 리스너
|
|
useEffect(() => {
|
|
const handleRelatedButtonRegister = (event: CustomEvent) => {
|
|
const { targetTable } = event.detail || {};
|
|
if (targetTable === tableConfig.selectedTable) {
|
|
setIsRelatedButtonTarget(true);
|
|
}
|
|
};
|
|
|
|
const handleRelatedButtonUnregister = (event: CustomEvent) => {
|
|
const { targetTable } = event.detail || {};
|
|
if (targetTable === tableConfig.selectedTable) {
|
|
setIsRelatedButtonTarget(false);
|
|
setRelatedButtonFilter(null);
|
|
}
|
|
};
|
|
|
|
// V2 EventBus 구독
|
|
const unsubscribeRegister = v2EventBus.subscribe(
|
|
V2_EVENTS.RELATED_BUTTON_REGISTER,
|
|
(payload) => {
|
|
if (payload.targetTables.includes(tableConfig.selectedTable || "")) {
|
|
setIsRelatedButtonTarget(true);
|
|
}
|
|
},
|
|
{ componentId: component.id }
|
|
);
|
|
|
|
const unsubscribeUnregister = v2EventBus.subscribe(
|
|
V2_EVENTS.RELATED_BUTTON_UNREGISTER,
|
|
(payload) => {
|
|
if (payload.buttonId) {
|
|
setIsRelatedButtonTarget(false);
|
|
setRelatedButtonFilter(null);
|
|
}
|
|
},
|
|
{ componentId: component.id }
|
|
);
|
|
|
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
window.addEventListener("related-button-register" as any, handleRelatedButtonRegister);
|
|
window.addEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
|
|
|
|
return () => {
|
|
unsubscribeRegister();
|
|
unsubscribeUnregister();
|
|
window.removeEventListener("related-button-register" as any, handleRelatedButtonRegister);
|
|
window.removeEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
|
|
};
|
|
}, [tableConfig.selectedTable, component.id]);
|
|
|
|
// RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
|
|
useEffect(() => {
|
|
const handleRelatedButtonSelect = (event: CustomEvent) => {
|
|
const { targetTable, filterColumn, filterValue } = event.detail || {};
|
|
|
|
// 이 테이블이 대상 테이블인지 확인
|
|
if (targetTable === tableConfig.selectedTable) {
|
|
// filterValue가 null이면 선택 해제 (빈 상태)
|
|
if (filterValue === null || filterValue === undefined) {
|
|
setRelatedButtonFilter(null);
|
|
setIsRelatedButtonTarget(true); // 대상으로 등록은 유지
|
|
} else {
|
|
setRelatedButtonFilter({ filterColumn, filterValue });
|
|
setIsRelatedButtonTarget(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
// V2 EventBus 구독
|
|
const unsubscribeSelect = v2EventBus.subscribe(
|
|
V2_EVENTS.RELATED_BUTTON_SELECT,
|
|
(payload) => {
|
|
if (payload.tableName === tableConfig.selectedTable) {
|
|
if (!payload.selectedData || payload.selectedData.length === 0) {
|
|
setRelatedButtonFilter(null);
|
|
setIsRelatedButtonTarget(true);
|
|
} else {
|
|
// 첫 번째 선택된 데이터의 ID를 필터로 사용
|
|
const firstItem = payload.selectedData[0];
|
|
if (firstItem?.id) {
|
|
setRelatedButtonFilter({ filterColumn: "id", filterValue: firstItem.id });
|
|
}
|
|
setIsRelatedButtonTarget(true);
|
|
}
|
|
}
|
|
},
|
|
{ componentId: component.id }
|
|
);
|
|
|
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
|
|
|
return () => {
|
|
unsubscribeSelect();
|
|
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
|
};
|
|
}, [tableConfig.selectedTable, component.id]);
|
|
|
|
// 🆕 relatedButtonFilter 변경 시 데이터 다시 로드
|
|
useEffect(() => {
|
|
if (!isDesignMode) {
|
|
// relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거)
|
|
// RelatedDataButtons 상태 변경
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
}
|
|
}, [relatedButtonFilter, isDesignMode]);
|
|
|
|
// 🎯 컬럼 너비 자동 계산 (내용 기반)
|
|
const calculateOptimalColumnWidth = useCallback(
|
|
(columnName: string, displayName: string): number => {
|
|
// 기본 너비 설정
|
|
const MIN_WIDTH = 100;
|
|
const MAX_WIDTH = 400;
|
|
const PADDING = 48; // 좌우 패딩 + 여유 공간
|
|
const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등)
|
|
|
|
// 헤더 텍스트 너비 계산 (대략 8px per character)
|
|
const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING;
|
|
|
|
// 데이터 셀 너비 계산 (상위 50개 샘플링)
|
|
const sampleSize = Math.min(50, data.length);
|
|
let maxDataWidth = headerWidth;
|
|
|
|
for (let i = 0; i < sampleSize; i++) {
|
|
const cellValue = data[i]?.[columnName];
|
|
if (cellValue !== null && cellValue !== undefined) {
|
|
const cellText = String(cellValue);
|
|
// 숫자는 좁게, 텍스트는 넓게 계산
|
|
const isNumber = !isNaN(Number(cellValue)) && cellValue !== "";
|
|
const charWidth = isNumber ? 8 : 9;
|
|
const cellWidth = cellText.length * charWidth + PADDING;
|
|
maxDataWidth = Math.max(maxDataWidth, cellWidth);
|
|
}
|
|
}
|
|
|
|
// 최소/최대 범위 내로 제한
|
|
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth)));
|
|
},
|
|
[data],
|
|
);
|
|
|
|
// 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산
|
|
useEffect(() => {
|
|
if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) {
|
|
const timer = setTimeout(() => {
|
|
const storageKey =
|
|
tableConfig.selectedTable && userId ? `table_column_widths_${tableConfig.selectedTable}_${userId}` : null;
|
|
|
|
// 1. localStorage에서 저장된 너비 불러오기
|
|
let savedWidths: Record<string, number> = {};
|
|
if (storageKey) {
|
|
try {
|
|
const saved = localStorage.getItem(storageKey);
|
|
if (saved) {
|
|
savedWidths = JSON.parse(saved);
|
|
}
|
|
} catch (error) {
|
|
console.error("컬럼 너비 불러오기 실패:", error);
|
|
}
|
|
}
|
|
|
|
// 2. 자동 계산 또는 저장된 너비 적용
|
|
const newWidths: Record<string, number> = {};
|
|
let hasAnyWidth = false;
|
|
|
|
visibleColumns.forEach((column) => {
|
|
// 체크박스 컬럼은 제외 (고정 48px)
|
|
if (column.columnName === "__checkbox__") return;
|
|
|
|
// 저장된 너비가 있으면 우선 사용
|
|
if (savedWidths[column.columnName]) {
|
|
newWidths[column.columnName] = savedWidths[column.columnName];
|
|
hasAnyWidth = true;
|
|
} else {
|
|
// 저장된 너비가 없으면 자동 계산
|
|
const optimalWidth = calculateOptimalColumnWidth(
|
|
column.columnName,
|
|
columnLabels[column.columnName] || column.displayName,
|
|
);
|
|
newWidths[column.columnName] = optimalWidth;
|
|
hasAnyWidth = true;
|
|
}
|
|
});
|
|
|
|
if (hasAnyWidth) {
|
|
setColumnWidths(newWidths);
|
|
hasInitializedWidths.current = true;
|
|
}
|
|
}, 150); // DOM 렌더링 대기
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [visibleColumns, data, tableConfig.selectedTable, userId, calculateOptimalColumnWidth, columnLabels]);
|
|
|
|
// ========================================
|
|
// 페이지네이션 JSX
|
|
// ========================================
|
|
|
|
const paginationJSX = useMemo(() => {
|
|
if (!tableConfig.pagination?.enabled || isDesignMode) return null;
|
|
|
|
// 페이지 크기 변경 핸들러
|
|
const handlePageSizeChange = (newSize: number) => {
|
|
setLocalPageSize(newSize);
|
|
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 이동
|
|
if (onConfigChange) {
|
|
onConfigChange({
|
|
...tableConfig,
|
|
pagination: { ...tableConfig.pagination, pageSize: newSize, currentPage: 1 },
|
|
});
|
|
}
|
|
};
|
|
|
|
const pageSizeOptions = tableConfig.pagination?.pageSizeOptions || [5, 10, 20, 50, 100];
|
|
|
|
return (
|
|
<div className="border-border bg-background relative flex h-14 w-full flex-shrink-0 items-center justify-center border-t-2 px-4 sm:h-[60px] sm:px-6">
|
|
{/* 좌측: 페이지 크기 입력 */}
|
|
<div className="absolute left-2 flex items-center gap-1 sm:left-6 sm:gap-2">
|
|
<span className="text-muted-foreground hidden text-xs sm:inline">표시:</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={10000}
|
|
value={localPageSize}
|
|
onChange={(e) => {
|
|
const value = Math.min(10000, Math.max(1, Number(e.target.value) || 1));
|
|
handlePageSizeChange(value);
|
|
}}
|
|
onBlur={(e) => {
|
|
// 포커스 잃을 때 유효 범위로 조정
|
|
const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10));
|
|
handlePageSizeChange(value);
|
|
}}
|
|
className="border-input bg-background focus:ring-ring h-7 w-14 rounded-md border px-2 text-center text-xs focus:ring-1 focus:outline-none sm:h-8 sm:w-16"
|
|
/>
|
|
<span className="text-muted-foreground text-xs">개</span>
|
|
</div>
|
|
|
|
{/* 중앙 페이지네이션 컨트롤 */}
|
|
<div className="flex items-center gap-2 sm:gap-4">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(1)}
|
|
disabled={currentPage === 1 || loading}
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1 || loading}
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
</Button>
|
|
|
|
<span className="text-foreground min-w-[60px] text-center text-xs font-medium sm:min-w-[80px] sm:text-sm">
|
|
{currentPage} / {totalPages || 1}
|
|
</span>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage >= totalPages || loading}
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(totalPages)}
|
|
disabled={currentPage >= totalPages || loading}
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 우측 버튼 그룹 */}
|
|
<div className="absolute right-2 flex items-center gap-1 sm:right-6">
|
|
{/* 🆕 내보내기 버튼 (Excel/PDF) */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3" title="내보내기">
|
|
<Download className="h-3 w-3" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-56 p-2" align="end">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">Excel</div>
|
|
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToExcel(true)}>
|
|
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
|
|
전체 Excel 내보내기
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="justify-start text-xs"
|
|
onClick={() => exportToExcel(false)}
|
|
disabled={selectedRows.size === 0}
|
|
>
|
|
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
|
|
선택 항목만 ({selectedRows.size}개)
|
|
</Button>
|
|
<div className="border-border my-1 border-t" />
|
|
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">PDF/인쇄</div>
|
|
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToPdf(true)}>
|
|
<FileText className="mr-2 h-3 w-3 text-red-600" />
|
|
전체 PDF 내보내기
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="justify-start text-xs"
|
|
onClick={() => exportToPdf(false)}
|
|
disabled={selectedRows.size === 0}
|
|
>
|
|
<FileText className="mr-2 h-3 w-3 text-red-600" />
|
|
선택 항목만 ({selectedRows.size}개)
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* 새로고침 버튼 (하단 페이지네이션) */}
|
|
{(tableConfig.toolbar?.showPaginationRefresh ?? true) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}, [
|
|
tableConfig.pagination,
|
|
tableConfig.toolbar?.showPaginationRefresh,
|
|
isDesignMode,
|
|
currentPage,
|
|
totalPages,
|
|
totalItems,
|
|
loading,
|
|
selectedRows.size,
|
|
exportToExcel,
|
|
exportToPdf,
|
|
localPageSize,
|
|
onConfigChange,
|
|
tableConfig,
|
|
]);
|
|
|
|
// ========================================
|
|
// 렌더링
|
|
// ========================================
|
|
|
|
const domProps = {
|
|
onClick: handleClick,
|
|
onDragStart: isDesignMode ? onDragStart : undefined,
|
|
onDragEnd: isDesignMode ? onDragEnd : undefined,
|
|
draggable: isDesignMode,
|
|
className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일
|
|
style: componentStyle,
|
|
};
|
|
|
|
// 카드 모드
|
|
if (tableConfig.displayMode === "card" && !isDesignMode) {
|
|
return (
|
|
<div {...domProps}>
|
|
{loading ? (
|
|
<div className="flex h-40 items-center justify-center">
|
|
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex h-40 items-center justify-center">
|
|
<span className="text-destructive text-sm">{error}</span>
|
|
</div>
|
|
) : (
|
|
<CardModeRenderer
|
|
data={data}
|
|
cardConfig={
|
|
tableConfig.cardConfig || {
|
|
idColumn: visibleColumns[0]?.columnName || "id",
|
|
titleColumn: visibleColumns[0]?.columnName || "",
|
|
cardsPerRow: 3,
|
|
cardSpacing: 16,
|
|
showActions: false,
|
|
}
|
|
}
|
|
visibleColumns={visibleColumns}
|
|
onRowClick={handleRowClick}
|
|
selectedRows={Array.from(selectedRows)}
|
|
/>
|
|
)}
|
|
{paginationJSX}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// SingleTableWithSticky 모드
|
|
if (tableConfig.stickyHeader && !isDesignMode) {
|
|
return (
|
|
<div {...domProps}>
|
|
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
|
|
|
{/* 그룹 표시 배지 */}
|
|
{groupByColumns.length > 0 && (
|
|
<div className="border-border bg-muted/30 border-b px-4 py-1.5 sm:px-6">
|
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
|
<span className="text-muted-foreground">그룹:</span>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{groupByColumns.map((col, idx) => (
|
|
<span key={col} className="flex items-center">
|
|
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
|
{columnLabels[col] || col}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={clearGrouping}
|
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
|
title="그룹 해제"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
|
<SingleTableWithSticky
|
|
data={data}
|
|
columns={visibleColumns}
|
|
loading={loading}
|
|
error={error}
|
|
sortColumn={sortColumn}
|
|
sortDirection={sortDirection}
|
|
onSort={handleSort}
|
|
tableConfig={tableConfig}
|
|
isDesignMode={isDesignMode}
|
|
isAllSelected={isAllSelected}
|
|
handleSelectAll={handleSelectAll}
|
|
handleRowClick={handleRowClick}
|
|
columnLabels={columnLabels}
|
|
renderCheckboxHeader={renderCheckboxHeader}
|
|
renderCheckboxCell={renderCheckboxCell}
|
|
formatCellValue={(value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => {
|
|
const column = visibleColumns.find((c) => c.columnName === columnName);
|
|
return column ? formatCellValue(value, column, rowData) : String(value);
|
|
}}
|
|
getColumnWidth={getColumnWidth}
|
|
containerWidth={calculatedWidth}
|
|
/>
|
|
</div>
|
|
|
|
{paginationJSX}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 일반 테이블 모드 (네이티브 HTML 테이블)
|
|
return (
|
|
<>
|
|
<div {...domProps}>
|
|
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
|
|
|
{/* 🆕 DevExpress 스타일 기능 툴바 */}
|
|
<div className="border-border bg-muted/20 flex flex-wrap items-center gap-1 border-b px-2 py-1.5 sm:gap-2 sm:px-4 sm:py-2">
|
|
{/* 편집 모드 토글 */}
|
|
{(tableConfig.toolbar?.showEditMode ?? false) && (
|
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
|
<Button
|
|
variant={editMode === "batch" ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setEditMode(editMode === "batch" ? "immediate" : "batch")}
|
|
className="h-7 text-xs"
|
|
title="배치 편집 모드 (Ctrl+B)"
|
|
>
|
|
<Edit className="mr-1 h-3 w-3" />
|
|
{editMode === "batch" ? "배치 모드" : "즉시 저장"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 내보내기 버튼들 */}
|
|
{((tableConfig.toolbar?.showExcel ?? false) || (tableConfig.toolbar?.showPdf ?? false)) && (
|
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
|
{(tableConfig.toolbar?.showExcel ?? false) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => exportToExcel(true)}
|
|
className="h-7 text-xs"
|
|
title="Excel 내보내기"
|
|
>
|
|
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
|
|
Excel
|
|
</Button>
|
|
)}
|
|
{(tableConfig.toolbar?.showPdf ?? false) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => exportToPdf(true)}
|
|
className="h-7 text-xs"
|
|
title="PDF 내보내기"
|
|
>
|
|
<FileText className="mr-1 h-3 w-3 text-red-600" />
|
|
PDF
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 복사 버튼 */}
|
|
{(tableConfig.toolbar?.showCopy ?? false) && (
|
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleCopy}
|
|
disabled={selectedRows.size === 0 && !focusedCell}
|
|
className="h-7 text-xs"
|
|
title="복사 (Ctrl+C)"
|
|
>
|
|
<Copy className="mr-1 h-3 w-3" />
|
|
복사
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 선택 정보 - 숨김 처리 */}
|
|
|
|
{/* 🆕 통합 검색 패널 */}
|
|
{(tableConfig.toolbar?.showSearch ?? false) && (
|
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
|
{isSearchPanelOpen ? (
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="text"
|
|
value={globalSearchTerm}
|
|
onChange={(e) => setGlobalSearchTerm(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
executeGlobalSearch(globalSearchTerm);
|
|
} else if (e.key === "Escape") {
|
|
clearGlobalSearch();
|
|
} else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) {
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
goToPrevSearchResult();
|
|
} else {
|
|
goToNextSearchResult();
|
|
}
|
|
}
|
|
}}
|
|
placeholder="검색어 입력... (Enter)"
|
|
className="border-input bg-background focus:ring-primary h-7 w-32 rounded border px-2 text-xs focus:ring-1 focus:outline-none sm:w-48"
|
|
autoFocus
|
|
/>
|
|
{searchHighlights.size > 0 && (
|
|
<span className="text-muted-foreground text-xs">{searchHighlights.size}개</span>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={goToPrevSearchResult}
|
|
disabled={searchHighlights.size === 0}
|
|
className="h-6 w-6 p-0"
|
|
title="이전 (Shift+F3)"
|
|
>
|
|
<ChevronLeft className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={goToNextSearchResult}
|
|
disabled={searchHighlights.size === 0}
|
|
className="h-6 w-6 p-0"
|
|
title="다음 (F3)"
|
|
>
|
|
<ChevronRight className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearGlobalSearch}
|
|
className="h-6 w-6 p-0"
|
|
title="닫기 (Esc)"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setIsSearchPanelOpen(true)}
|
|
className="h-7 text-xs"
|
|
title="통합 검색 (Ctrl+F)"
|
|
>
|
|
<Filter className="mr-1 h-3 w-3" />
|
|
검색
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
|
|
{(tableConfig.toolbar?.showFilter ?? false) && (
|
|
<div className="border-border flex items-center gap-1 border-r pr-2">
|
|
<Button
|
|
variant={activeFilterCount > 0 ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setIsFilterBuilderOpen(true)}
|
|
className="h-7 text-xs"
|
|
title="고급 필터"
|
|
>
|
|
<Layers className="mr-1 h-3 w-3" />
|
|
필터
|
|
{activeFilterCount > 0 && (
|
|
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">{activeFilterCount}</span>
|
|
)}
|
|
</Button>
|
|
{activeFilterCount > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearFilterBuilder}
|
|
className="h-6 w-6 p-0"
|
|
title="필터 초기화"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 새로고침 (상단) */}
|
|
{(tableConfig.toolbar?.showRefresh ?? false) && (
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
className="h-7 text-xs"
|
|
title="새로고침"
|
|
>
|
|
<RefreshCw className={cn("mr-1 h-3 w-3", loading && "animate-spin")} />
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 🆕 배치 편집 툴바 */}
|
|
{(editMode === "batch" || pendingChanges.size > 0) && (
|
|
<div className="border-border flex items-center justify-between border-b bg-amber-50 px-4 py-2 sm:px-6 dark:bg-amber-950/30">
|
|
<div className="flex items-center gap-3 text-xs sm:text-sm">
|
|
<span className="rounded bg-amber-100 px-2 py-1 font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
|
배치 편집 모드
|
|
</span>
|
|
{pendingChanges.size > 0 && (
|
|
<span className="text-amber-600 dark:text-amber-400">{pendingChanges.size}개 변경사항</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={cancelBatchChanges}
|
|
disabled={pendingChanges.size === 0}
|
|
className="h-7 text-xs"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={saveBatchChanges}
|
|
disabled={pendingChanges.size === 0}
|
|
className="h-7 text-xs"
|
|
>
|
|
저장 ({pendingChanges.size})
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 그룹 표시 배지 */}
|
|
{groupByColumns.length > 0 && (
|
|
<div className="border-border bg-muted/30 border-b px-4 py-2 sm:px-6">
|
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
|
<span className="text-muted-foreground">그룹:</span>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{groupByColumns.map((col, idx) => (
|
|
<span key={col} className="flex items-center">
|
|
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
|
{columnLabels[col] || col}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={clearGrouping}
|
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
|
title="그룹 해제"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블 컨테이너 - 키보드 네비게이션 지원 */}
|
|
<div
|
|
ref={tableContainerRef}
|
|
className="flex flex-1 flex-col focus:outline-none"
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
overflow: "hidden",
|
|
}}
|
|
tabIndex={0}
|
|
onKeyDown={handleTableKeyDown}
|
|
role="grid"
|
|
aria-label="데이터 테이블"
|
|
>
|
|
{/* 스크롤 영역 */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="bg-background flex-1"
|
|
style={{
|
|
position: "relative",
|
|
width: "100%",
|
|
height: "100%",
|
|
overflow: "auto",
|
|
}}
|
|
onScroll={handleVirtualScroll}
|
|
>
|
|
{/* 테이블 */}
|
|
<table
|
|
className={cn("table-mobile-fixed", !showGridLines && "hide-grid")}
|
|
style={{
|
|
borderCollapse: "collapse",
|
|
width: "100%",
|
|
tableLayout: "fixed",
|
|
}}
|
|
>
|
|
{/* 헤더 (sticky) */}
|
|
<thead
|
|
className="sticky z-50"
|
|
style={{
|
|
position: "sticky",
|
|
top: 0,
|
|
zIndex: 50,
|
|
backgroundColor: "hsl(var(--background))",
|
|
}}
|
|
>
|
|
{/* 🆕 Multi-Level Headers (Column Bands) */}
|
|
{columnBandsInfo?.hasBands && (
|
|
<tr
|
|
className="border-primary/10 bg-muted/70 h-8 border-b sm:h-10"
|
|
style={{ backgroundColor: "hsl(var(--muted) / 0.7)" }}
|
|
>
|
|
{visibleColumns.map((column, colIdx) => {
|
|
// 이 컬럼이 속한 band 찾기
|
|
const band = columnBandsInfo.bands.find(
|
|
(b) => b.columns.includes(column.columnName) && b.startIndex === colIdx,
|
|
);
|
|
|
|
// band의 첫 번째 컬럼인 경우에만 렌더링
|
|
if (band) {
|
|
return (
|
|
<th
|
|
key={`band-${column.columnName}`}
|
|
colSpan={band.colSpan}
|
|
className="border-primary/10 border-r px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm"
|
|
>
|
|
{band.caption}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
// band에 속하지 않은 컬럼 (개별 표시)
|
|
const isInAnyBand = columnBandsInfo.bands.some((b) => b.columns.includes(column.columnName));
|
|
if (!isInAnyBand) {
|
|
return (
|
|
<th
|
|
key={`noband-${column.columnName}`}
|
|
rowSpan={2}
|
|
className="border-primary/10 border-r px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm"
|
|
>
|
|
{columnLabels[column.columnName] || column.columnName}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
// band의 중간 컬럼은 렌더링하지 않음
|
|
return null;
|
|
})}
|
|
</tr>
|
|
)}
|
|
<tr
|
|
className="border-primary/20 bg-muted h-10 border-b-2 sm:h-12"
|
|
style={{
|
|
backgroundColor: "hsl(var(--muted))",
|
|
}}
|
|
>
|
|
{visibleColumns.map((column, columnIndex) => {
|
|
const columnWidth = columnWidths[column.columnName];
|
|
const isFrozen = frozenColumns.includes(column.columnName);
|
|
const frozenIndex = frozenColumns.indexOf(column.columnName);
|
|
|
|
// 틀고정된 컬럼의 left 위치 계산
|
|
let leftPosition = 0;
|
|
if (isFrozen && frozenIndex > 0) {
|
|
for (let i = 0; i < frozenIndex; i++) {
|
|
const frozenCol = frozenColumns[i];
|
|
// 체크박스 컬럼은 48px 고정
|
|
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
|
leftPosition += frozenColWidth;
|
|
}
|
|
}
|
|
|
|
// 🆕 Column Reordering 상태
|
|
const isColumnDragging = draggedColumnIndex === columnIndex;
|
|
const isColumnDropTarget = dropTargetColumnIndex === columnIndex;
|
|
|
|
return (
|
|
<th
|
|
key={column.columnName}
|
|
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
|
className={cn(
|
|
"text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm",
|
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
|
column.sortable !== false &&
|
|
column.columnName !== "__checkbox__" &&
|
|
"hover:bg-muted/70 cursor-pointer transition-colors",
|
|
isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
|
|
// 🆕 Column Reordering 스타일
|
|
isColumnDragEnabled &&
|
|
column.columnName !== "__checkbox__" &&
|
|
"cursor-grab active:cursor-grabbing",
|
|
isColumnDragging && "bg-primary/20 opacity-50",
|
|
isColumnDropTarget && "border-l-primary border-l-4",
|
|
)}
|
|
style={{
|
|
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
|
width:
|
|
column.columnName === "__checkbox__"
|
|
? "48px"
|
|
: columnWidth
|
|
? `${columnWidth}px`
|
|
: undefined,
|
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
userSelect: "none",
|
|
backgroundColor: "hsl(var(--muted))",
|
|
...(isFrozen && { left: `${leftPosition}px` }),
|
|
}}
|
|
// 🆕 Column Reordering 이벤트
|
|
draggable={isColumnDragEnabled && column.columnName !== "__checkbox__"}
|
|
onDragStart={(e) => handleColumnDragStart(e, columnIndex)}
|
|
onDragOver={(e) => handleColumnDragOver(e, columnIndex)}
|
|
onDragEnd={handleColumnDragEnd}
|
|
onDrop={(e) => handleColumnDrop(e, columnIndex)}
|
|
onClick={() => {
|
|
if (isResizing.current) return;
|
|
if (column.sortable !== false && column.columnName !== "__checkbox__") {
|
|
handleSort(column.columnName);
|
|
}
|
|
}}
|
|
>
|
|
{column.columnName === "__checkbox__" ? (
|
|
renderCheckboxHeader()
|
|
) : (
|
|
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
|
|
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
|
{column.sortable !== false && sortColumn === column.columnName && (
|
|
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
|
)}
|
|
{/* 🆕 헤더 필터 버튼 */}
|
|
{tableConfig.headerFilter !== false &&
|
|
columnUniqueValues[column.columnName]?.length > 0 && (
|
|
<Popover
|
|
open={openFilterColumn === column.columnName}
|
|
onOpenChange={(open) => setOpenFilterColumn(open ? column.columnName : null)}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setOpenFilterColumn(
|
|
openFilterColumn === column.columnName ? null : column.columnName,
|
|
);
|
|
}}
|
|
className={cn(
|
|
"hover:bg-primary/20 ml-1 rounded p-0.5 transition-colors",
|
|
headerFilters[column.columnName]?.size > 0 && "text-primary bg-primary/10",
|
|
)}
|
|
title="필터"
|
|
>
|
|
<Filter className="h-3 w-3" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-48 p-2"
|
|
align="start"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between border-b pb-2">
|
|
<span className="text-xs font-medium">
|
|
필터: {columnLabels[column.columnName] || column.displayName}
|
|
</span>
|
|
{headerFilters[column.columnName]?.size > 0 && (
|
|
<button
|
|
onClick={() => clearHeaderFilter(column.columnName)}
|
|
className="text-destructive text-xs hover:underline"
|
|
>
|
|
초기화
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
|
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
|
|
const isSelected = headerFilters[column.columnName]?.has(val);
|
|
return (
|
|
<div
|
|
key={val}
|
|
className={cn(
|
|
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
|
|
isSelected && "bg-primary/10",
|
|
)}
|
|
onClick={() => toggleHeaderFilter(column.columnName, val)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex h-4 w-4 items-center justify-center rounded border",
|
|
isSelected ? "bg-primary border-primary" : "border-input",
|
|
)}
|
|
>
|
|
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
|
|
</div>
|
|
<span className="truncate">{val || "(빈 값)"}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{(columnUniqueValues[column.columnName]?.length || 0) > 50 && (
|
|
<div className="text-muted-foreground px-2 py-1 text-xs">
|
|
...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* 리사이즈 핸들 (체크박스 제외) */}
|
|
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
|
|
<div
|
|
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
|
|
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
|
|
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const thElement = columnRefs.current[column.columnName];
|
|
if (!thElement) return;
|
|
|
|
isResizing.current = true;
|
|
|
|
const startX = e.clientX;
|
|
const startWidth = columnWidth || thElement.offsetWidth;
|
|
|
|
// 드래그 중 텍스트 선택 방지
|
|
document.body.style.userSelect = "none";
|
|
document.body.style.cursor = "col-resize";
|
|
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
moveEvent.preventDefault();
|
|
|
|
const diff = moveEvent.clientX - startX;
|
|
const newWidth = Math.max(80, startWidth + diff);
|
|
|
|
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
|
if (thElement) {
|
|
thElement.style.width = `${newWidth}px`;
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
// 최종 너비를 state에 저장
|
|
if (thElement) {
|
|
const finalWidth = Math.max(80, thElement.offsetWidth);
|
|
setColumnWidths((prev) => {
|
|
const newWidths = { ...prev, [column.columnName]: finalWidth };
|
|
|
|
// 🎯 localStorage에 컬럼 너비 저장 (사용자별)
|
|
if (tableConfig.selectedTable && userId) {
|
|
const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`;
|
|
try {
|
|
localStorage.setItem(storageKey, JSON.stringify(newWidths));
|
|
} catch (error) {
|
|
console.error("컬럼 너비 저장 실패:", error);
|
|
}
|
|
}
|
|
|
|
return newWidths;
|
|
});
|
|
}
|
|
|
|
// 텍스트 선택 복원
|
|
document.body.style.userSelect = "";
|
|
document.body.style.cursor = "";
|
|
|
|
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
|
|
setTimeout(() => {
|
|
isResizing.current = false;
|
|
}, 100);
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
}}
|
|
/>
|
|
)}
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
|
|
{/* 바디 (스크롤) */}
|
|
<tbody key={`tbody-${categoryMappingsKey}`} style={{ position: "relative" }}>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<RefreshCw className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
<div className="text-muted-foreground text-sm font-medium">로딩 중...</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : error ? (
|
|
<tr>
|
|
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="text-destructive text-sm font-medium">오류 발생</div>
|
|
<div className="text-muted-foreground text-xs">{error}</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : data.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<TableIcon className="text-muted-foreground/50 h-12 w-12" />
|
|
<div className="text-muted-foreground text-sm font-medium">데이터가 없습니다</div>
|
|
<div className="text-muted-foreground text-xs">
|
|
조건을 변경하거나 새로운 데이터를 추가해보세요
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
|
|
// 그룹화된 렌더링
|
|
groupedData.map((group) => {
|
|
const isCollapsed = collapsedGroups.has(group.groupKey);
|
|
return (
|
|
<React.Fragment key={group.groupKey}>
|
|
{/* 그룹 헤더 */}
|
|
<tr>
|
|
<td
|
|
colSpan={visibleColumns.length}
|
|
className="bg-muted/50 border-border sticky top-[48px] z-[5] border-b"
|
|
style={{ top: "48px" }}
|
|
>
|
|
<div
|
|
className="hover:bg-muted flex cursor-pointer items-center gap-3 p-3"
|
|
onClick={() => toggleGroupCollapse(group.groupKey)}
|
|
>
|
|
{isCollapsed ? (
|
|
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
|
)}
|
|
<span className="flex-1 text-sm font-medium">{group.groupKey}</span>
|
|
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/* 그룹 데이터 */}
|
|
{!isCollapsed &&
|
|
group.items.map((row, index) => (
|
|
<tr
|
|
key={index}
|
|
className={cn(
|
|
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
|
)}
|
|
onClick={(e) => handleRowClick(row, index, e)}
|
|
>
|
|
{visibleColumns.map((column, colIndex) => {
|
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
|
const cellValue = row[mappedColumnName];
|
|
|
|
const meta = columnMeta[column.columnName];
|
|
const inputType = meta?.inputType || column.inputType;
|
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
|
|
|
const isFrozen = frozenColumns.includes(column.columnName);
|
|
const frozenIndex = frozenColumns.indexOf(column.columnName);
|
|
|
|
// 틀고정된 컬럼의 left 위치 계산
|
|
let leftPosition = 0;
|
|
if (isFrozen && frozenIndex > 0) {
|
|
for (let i = 0; i < frozenIndex; i++) {
|
|
const frozenCol = frozenColumns[i];
|
|
// 체크박스 컬럼은 48px 고정
|
|
const frozenColWidth =
|
|
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
|
leftPosition += frozenColWidth;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<td
|
|
key={column.columnName}
|
|
className={cn(
|
|
"text-foreground text-xs font-normal sm:text-sm",
|
|
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
|
|
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
|
|
column.columnName === "__checkbox__"
|
|
? "px-0 py-1"
|
|
: "px-2 py-1 sm:px-4 sm:py-1.5",
|
|
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
|
)}
|
|
style={{
|
|
textAlign:
|
|
column.columnName === "__checkbox__"
|
|
? "center"
|
|
: isNumeric
|
|
? "right"
|
|
: column.align || "left",
|
|
width:
|
|
column.columnName === "__checkbox__"
|
|
? "48px"
|
|
: `${100 / visibleColumns.length}%`,
|
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
...(isFrozen && {
|
|
left: `${leftPosition}px`,
|
|
backgroundColor: "hsl(var(--background))",
|
|
}),
|
|
}}
|
|
>
|
|
{column.columnName === "__checkbox__"
|
|
? renderCheckboxCell(row, index)
|
|
: formatCellValue(cellValue, column, row)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
{/* 🆕 그룹별 소계 행 */}
|
|
{!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && (
|
|
<tr className="bg-muted/30 border-primary/20 border-b-2">
|
|
{visibleColumns.map((column, colIndex) => {
|
|
const summary = group.summary?.[column.columnName];
|
|
const meta = columnMeta[column.columnName];
|
|
const inputType = meta?.inputType || (column as any).inputType;
|
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
|
|
|
if (colIndex === 0 && column.columnName === "__checkbox__") {
|
|
return (
|
|
<td key={column.columnName} className="px-2 py-1 sm:px-4">
|
|
<span className="text-muted-foreground text-xs font-medium">소계</span>
|
|
</td>
|
|
);
|
|
}
|
|
|
|
if (colIndex === 0 && column.columnName !== "__checkbox__") {
|
|
return (
|
|
<td key={column.columnName} className="px-2 py-1 sm:px-4">
|
|
<span className="text-muted-foreground text-xs font-medium">
|
|
소계 ({group.count}건)
|
|
</span>
|
|
</td>
|
|
);
|
|
}
|
|
|
|
if (summary) {
|
|
return (
|
|
<td
|
|
key={column.columnName}
|
|
className="px-2 py-1 text-xs font-semibold sm:px-4"
|
|
style={{ textAlign: isNumeric ? "right" : "left" }}
|
|
>
|
|
{summary.sum.toLocaleString()}
|
|
</td>
|
|
);
|
|
}
|
|
|
|
return <td key={column.columnName} className="px-2 py-1 sm:px-4" />;
|
|
})}
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})
|
|
) : (
|
|
// 일반 렌더링 (그룹 없음) - 키보드 네비게이션 지원
|
|
<>
|
|
{/* 🆕 Virtual Scrolling: Top Spacer */}
|
|
{isVirtualScrollEnabled && virtualScrollInfo.topSpacerHeight > 0 && (
|
|
<tr style={{ height: `${virtualScrollInfo.topSpacerHeight}px` }}>
|
|
<td colSpan={visibleColumns.length} />
|
|
</tr>
|
|
)}
|
|
{/* 데이터 행 렌더링 - 🆕 합산 모드면 displayData 사용 */}
|
|
{(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : displayData).map((row, idx) => {
|
|
// Virtual Scrolling에서는 실제 인덱스 계산
|
|
const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx;
|
|
const rowKey = getRowKey(row, index);
|
|
const isRowSelected = selectedRows.has(rowKey);
|
|
const isRowFocused = focusedCell?.rowIndex === index;
|
|
|
|
// 🆕 Drag & Drop 상태
|
|
const isDragging = draggedRowIndex === index;
|
|
const isDropTarget = dropTargetIndex === index;
|
|
|
|
return (
|
|
<tr
|
|
key={index}
|
|
className={cn(
|
|
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
|
isRowSelected && "bg-primary/10 hover:bg-primary/15",
|
|
isRowFocused && "ring-primary/50 ring-1 ring-inset",
|
|
// 🆕 Drag & Drop 스타일
|
|
isDragEnabled && "cursor-grab active:cursor-grabbing",
|
|
isDragging && "bg-muted opacity-50",
|
|
isDropTarget && "border-t-primary border-t-2",
|
|
)}
|
|
onClick={(e) => handleRowClick(row, index, e)}
|
|
role="row"
|
|
aria-selected={isRowSelected}
|
|
// 🆕 Drag & Drop 이벤트
|
|
draggable={isDragEnabled}
|
|
onDragStart={(e) => handleRowDragStart(e, index)}
|
|
onDragOver={(e) => handleRowDragOver(e, index)}
|
|
onDragEnd={handleRowDragEnd}
|
|
onDrop={(e) => handleRowDrop(e, index)}
|
|
>
|
|
{visibleColumns.map((column, colIndex) => {
|
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
|
// 🆕 배치 편집: 로컬 수정 데이터 우선 표시
|
|
const cellValue =
|
|
editMode === "batch"
|
|
? getDisplayValue(row, index, mappedColumnName)
|
|
: row[mappedColumnName];
|
|
|
|
const meta = columnMeta[column.columnName];
|
|
const inputType = meta?.inputType || column.inputType;
|
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
|
|
|
const isFrozen = frozenColumns.includes(column.columnName);
|
|
const frozenIndex = frozenColumns.indexOf(column.columnName);
|
|
|
|
// 셀 포커스 상태
|
|
const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex;
|
|
|
|
// 🆕 배치 편집: 수정된 셀 여부
|
|
const isModified = isCellModified(index, mappedColumnName);
|
|
|
|
// 🆕 유효성 검사 에러
|
|
const cellValidationError = getCellValidationError(index, mappedColumnName);
|
|
|
|
// 🆕 검색 하이라이트 여부
|
|
const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`);
|
|
|
|
// 틀고정된 컬럼의 left 위치 계산
|
|
let leftPosition = 0;
|
|
if (isFrozen && frozenIndex > 0) {
|
|
for (let i = 0; i < frozenIndex; i++) {
|
|
const frozenCol = frozenColumns[i];
|
|
// 체크박스 컬럼은 48px 고정
|
|
const frozenColWidth =
|
|
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
|
leftPosition += frozenColWidth;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<td
|
|
key={column.columnName}
|
|
data-row={index}
|
|
data-col={colIndex}
|
|
className={cn(
|
|
"text-foreground text-xs font-normal sm:text-sm",
|
|
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
|
|
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
|
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
|
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
|
// 🆕 포커스된 셀 스타일
|
|
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
|
|
// 🆕 편집 중인 셀 스타일
|
|
editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0",
|
|
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
|
|
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
|
|
// 🆕 유효성 에러: 빨간 테두리 및 배경
|
|
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40",
|
|
// 🆕 검색 하이라이트 스타일 (노란 배경)
|
|
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
|
|
// 🆕 편집 불가 컬럼 스타일 (연한 회색 배경)
|
|
column.editable === false && "bg-gray-50 dark:bg-gray-900/30",
|
|
)}
|
|
// 🆕 유효성 에러 툴팁
|
|
title={cellValidationError || undefined}
|
|
style={{
|
|
textAlign:
|
|
column.columnName === "__checkbox__"
|
|
? "center"
|
|
: isNumeric
|
|
? "right"
|
|
: column.align || "left",
|
|
width:
|
|
column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`,
|
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
...(isFrozen && {
|
|
left: `${leftPosition}px`,
|
|
backgroundColor: "hsl(var(--background))",
|
|
}),
|
|
}}
|
|
onClick={(e) => handleCellClick(index, colIndex, e)}
|
|
onDoubleClick={() =>
|
|
handleCellDoubleClick(index, colIndex, column.columnName, cellValue)
|
|
}
|
|
onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)}
|
|
role="gridcell"
|
|
tabIndex={isCellFocused ? 0 : -1}
|
|
>
|
|
{/* 🆕 인라인 편집 모드 */}
|
|
{editingCell?.rowIndex === index && editingCell?.colIndex === colIndex
|
|
? // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력
|
|
(() => {
|
|
const cascadingConfig = (tableConfig as any).cascadingLookups?.[
|
|
column.columnName
|
|
];
|
|
const options = cascadingConfig
|
|
? getCascadingOptions(column.columnName, row)
|
|
: [];
|
|
|
|
// 부모 값이 변경되면 옵션 로딩
|
|
if (cascadingConfig && options.length === 0) {
|
|
const parentValue = row[cascadingConfig.parentColumn];
|
|
if (parentValue !== undefined && parentValue !== null) {
|
|
loadCascadingOptions(
|
|
column.columnName,
|
|
cascadingConfig.parentColumn,
|
|
parentValue,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운
|
|
const colMeta = columnMeta[column.columnName];
|
|
const isCategoryType =
|
|
colMeta?.inputType === "category" || colMeta?.inputType === "code";
|
|
const hasCategoryOptions =
|
|
categoryMappings[column.columnName] &&
|
|
Object.keys(categoryMappings[column.columnName]).length > 0;
|
|
|
|
if (cascadingConfig || (isCategoryType && hasCategoryOptions)) {
|
|
const selectOptions = cascadingConfig
|
|
? options
|
|
: Object.entries(categoryMappings[column.columnName] || {}).map(
|
|
([value, info]) => ({
|
|
value,
|
|
label: info.label,
|
|
}),
|
|
);
|
|
|
|
return (
|
|
<select
|
|
ref={editInputRef as any}
|
|
value={editingValue}
|
|
onChange={(e) => setEditingValue(e.target.value)}
|
|
onKeyDown={handleEditKeyDown}
|
|
onBlur={saveEditing}
|
|
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
|
autoFocus
|
|
>
|
|
<option value="">선택하세요</option>
|
|
{selectOptions.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
// 날짜 타입: 캘린더 피커
|
|
const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime";
|
|
if (isDateType) {
|
|
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
|
|
return (
|
|
<InlineCellDatePicker
|
|
value={editingValue}
|
|
onChange={setEditingValue}
|
|
onSave={saveEditing}
|
|
onKeyDown={handleEditKeyDown}
|
|
inputRef={editInputRef}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 일반 입력 필드
|
|
return (
|
|
<input
|
|
ref={editInputRef}
|
|
type={isNumeric ? "number" : "text"}
|
|
value={editingValue}
|
|
onChange={(e) => setEditingValue(e.target.value)}
|
|
onKeyDown={handleEditKeyDown}
|
|
onBlur={saveEditing}
|
|
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
|
style={{
|
|
textAlign: isNumeric ? "right" : column.align || "left",
|
|
}}
|
|
/>
|
|
);
|
|
})()
|
|
: column.columnName === "__checkbox__"
|
|
? renderCheckboxCell(row, index)
|
|
: formatCellValue(cellValue, column, row)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
{/* 🆕 Virtual Scrolling: Bottom Spacer */}
|
|
{isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && (
|
|
<tr style={{ height: `${virtualScrollInfo.bottomSpacerHeight}px` }}>
|
|
<td colSpan={visibleColumns.length} />
|
|
</tr>
|
|
)}
|
|
</>
|
|
)}
|
|
</tbody>
|
|
|
|
{/* 🆕 데이터 요약 (Total Summaries) */}
|
|
{summaryData && Object.keys(summaryData).length > 0 && (
|
|
<tfoot className="bg-muted/80 border-primary/20 sticky bottom-0 z-10 border-t-2">
|
|
<tr>
|
|
{visibleColumns.map((column, colIndex) => {
|
|
const summary = summaryData[column.columnName];
|
|
const columnWidth = columnWidths[column.columnName];
|
|
const isFrozen = frozenColumns.includes(column.columnName);
|
|
const frozenIndex = frozenColumns.indexOf(column.columnName);
|
|
|
|
// 틀고정된 컬럼의 left 위치 계산
|
|
let leftPosition = 0;
|
|
if (isFrozen && frozenIndex > 0) {
|
|
for (let i = 0; i < frozenIndex; i++) {
|
|
const frozenCol = frozenColumns[i];
|
|
// 체크박스 컬럼은 48px 고정
|
|
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
|
leftPosition += frozenColWidth;
|
|
}
|
|
}
|
|
|
|
const meta = columnMeta[column.columnName];
|
|
const inputType = meta?.inputType || (column as any).inputType;
|
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
|
|
|
return (
|
|
<td
|
|
key={column.columnName}
|
|
className={cn(
|
|
"text-foreground text-xs font-semibold sm:text-sm",
|
|
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-4",
|
|
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
|
)}
|
|
style={{
|
|
textAlign: isNumeric ? "right" : column.align || "left",
|
|
width:
|
|
column.columnName === "__checkbox__"
|
|
? "48px"
|
|
: columnWidth
|
|
? `${columnWidth}px`
|
|
: undefined,
|
|
...(isFrozen && {
|
|
left: `${leftPosition}px`,
|
|
backgroundColor: "hsl(var(--muted) / 0.8)",
|
|
}),
|
|
}}
|
|
>
|
|
{summary ? (
|
|
<div className="flex flex-col">
|
|
<span className="text-muted-foreground text-[10px] sm:text-xs">{summary.label}</span>
|
|
<span className="text-primary font-bold">
|
|
{typeof summary.value === "number"
|
|
? summary.value.toLocaleString("ko-KR", {
|
|
maximumFractionDigits: 2,
|
|
})
|
|
: summary.value}
|
|
</span>
|
|
</div>
|
|
) : colIndex === 0 ? (
|
|
<span className="text-muted-foreground text-xs">요약</span>
|
|
) : null}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
</tfoot>
|
|
)}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 페이지네이션 */}
|
|
{paginationJSX}
|
|
</div>
|
|
|
|
{/* 필터 설정 다이얼로그 */}
|
|
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">검색 필터 설정</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3 sm:space-y-4">
|
|
{/* 전체 선택/해제 */}
|
|
<div className="bg-muted/50 flex items-center gap-3 rounded border p-3">
|
|
<Checkbox
|
|
id="select-all-filters"
|
|
checked={
|
|
visibleFilterColumns.size ===
|
|
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length &&
|
|
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0
|
|
}
|
|
onCheckedChange={toggleAllFilters}
|
|
/>
|
|
<Label htmlFor="select-all-filters" className="flex-1 cursor-pointer text-xs font-semibold sm:text-sm">
|
|
전체 선택/해제
|
|
</Label>
|
|
<span className="text-muted-foreground text-xs">
|
|
{visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length}
|
|
개
|
|
</span>
|
|
</div>
|
|
|
|
{/* 컬럼 목록 */}
|
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
|
{visibleColumns
|
|
.filter((col) => col.columnName !== "__checkbox__")
|
|
.map((col) => (
|
|
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
|
<Checkbox
|
|
id={`filter-${col.columnName}`}
|
|
checked={visibleFilterColumns.has(col.columnName)}
|
|
onCheckedChange={() => toggleFilterVisibility(col.columnName)}
|
|
/>
|
|
<Label
|
|
htmlFor={`filter-${col.columnName}`}
|
|
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
|
>
|
|
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 선택된 컬럼 개수 안내 */}
|
|
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-center text-xs">
|
|
{visibleFilterColumns.size === 0 ? (
|
|
<span>검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요</span>
|
|
) : (
|
|
<span>
|
|
총 <span className="text-primary font-semibold">{visibleFilterColumns.size}개</span>의 검색 필터가
|
|
표시됩니다
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsFilterSettingOpen(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button onClick={saveFilterSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 🆕 Context Menu (우클릭 메뉴) */}
|
|
{contextMenu && (
|
|
<div
|
|
className="bg-popover text-popover-foreground fixed z-[9999] min-w-[160px] rounded-md border shadow-md"
|
|
style={{
|
|
left: contextMenu.x,
|
|
top: contextMenu.y,
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-1">
|
|
{/* 셀 복사 */}
|
|
<button
|
|
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
|
|
onClick={() => {
|
|
const col = visibleColumns[contextMenu.colIndex];
|
|
if (col) {
|
|
const value = contextMenu.row[col.columnName];
|
|
navigator.clipboard.writeText(String(value ?? ""));
|
|
toast.success("셀 값이 복사되었습니다");
|
|
}
|
|
closeContextMenu();
|
|
}}
|
|
>
|
|
<Copy className="h-4 w-4" />셀 복사
|
|
</button>
|
|
|
|
{/* 행 복사 */}
|
|
<button
|
|
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
|
|
onClick={() => {
|
|
const rowData = visibleColumns.map((col) => String(contextMenu.row[col.columnName] ?? "")).join("\t");
|
|
navigator.clipboard.writeText(rowData);
|
|
toast.success("행 데이터가 복사되었습니다");
|
|
closeContextMenu();
|
|
}}
|
|
>
|
|
<ClipboardCopy className="h-4 w-4" />행 복사
|
|
</button>
|
|
|
|
<div className="bg-border my-1 h-px" />
|
|
|
|
{/* 셀 편집 */}
|
|
{(() => {
|
|
const col = visibleColumns[contextMenu.colIndex];
|
|
const isEditable = col?.editable !== false && col?.columnName !== "__checkbox__";
|
|
return (
|
|
<button
|
|
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm ${
|
|
isEditable ? "hover:bg-accent hover:text-accent-foreground" : "cursor-not-allowed opacity-50"
|
|
}`}
|
|
onClick={() => {
|
|
if (!isEditable) {
|
|
toast.warning(`'${col?.displayName || col?.columnName}' 컬럼은 편집할 수 없습니다.`);
|
|
closeContextMenu();
|
|
return;
|
|
}
|
|
if (col) {
|
|
setEditingCell({
|
|
rowIndex: contextMenu.rowIndex,
|
|
colIndex: contextMenu.colIndex,
|
|
columnName: col.columnName,
|
|
originalValue: contextMenu.row[col.columnName],
|
|
});
|
|
setEditingValue(String(contextMenu.row[col.columnName] ?? ""));
|
|
}
|
|
closeContextMenu();
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4" />셀 편집 {!isEditable && "(잠김)"}
|
|
</button>
|
|
);
|
|
})()}
|
|
|
|
{/* 행 선택/해제 */}
|
|
<button
|
|
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
|
|
onClick={() => {
|
|
const rowKey = getRowKey(contextMenu.row, contextMenu.rowIndex);
|
|
setSelectedRows((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(rowKey)) {
|
|
next.delete(rowKey);
|
|
} else {
|
|
next.add(rowKey);
|
|
}
|
|
return next;
|
|
});
|
|
closeContextMenu();
|
|
}}
|
|
>
|
|
<CheckSquare className="h-4 w-4" />
|
|
{selectedRows.has(getRowKey(contextMenu.row, contextMenu.rowIndex)) ? "선택 해제" : "행 선택"}
|
|
</button>
|
|
|
|
<div className="bg-border my-1 h-px" />
|
|
|
|
{/* 행 삭제 */}
|
|
<button
|
|
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-red-600"
|
|
onClick={async () => {
|
|
if (confirm("이 행을 삭제하시겠습니까?")) {
|
|
try {
|
|
const rowId = contextMenu.row.id || contextMenu.row.uuid;
|
|
if (!rowId) {
|
|
toast.error("삭제할 행의 ID를 찾을 수 없습니다");
|
|
closeContextMenu();
|
|
return;
|
|
}
|
|
const tableName = tableConfig.selectedTable;
|
|
if (!tableName) {
|
|
toast.error("테이블명을 찾을 수 없습니다");
|
|
closeContextMenu();
|
|
return;
|
|
}
|
|
await tableTypeApi.deleteTableData(tableName, { ids: [String(rowId)] });
|
|
toast.success("행이 삭제되었습니다");
|
|
handleRefresh();
|
|
} catch (error) {
|
|
console.error("삭제 오류:", error);
|
|
toast.error("삭제 중 오류가 발생했습니다");
|
|
}
|
|
}
|
|
closeContextMenu();
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />행 삭제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 🆕 Filter Builder 모달 */}
|
|
<Dialog open={isFilterBuilderOpen} onOpenChange={setIsFilterBuilderOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">고급 필터</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
여러 조건을 조합하여 데이터를 필터링합니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
|
|
{filterGroups.length === 0 ? (
|
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
|
필터 조건이 없습니다. 아래 버튼을 클릭하여 조건을 추가하세요.
|
|
</div>
|
|
) : (
|
|
filterGroups.map((group, groupIndex) => (
|
|
<div key={group.id} className="rounded-lg border p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-xs">조건 그룹 {groupIndex + 1}</span>
|
|
<select
|
|
value={group.logic}
|
|
onChange={(e) => updateGroupLogic(group.id, e.target.value as "AND" | "OR")}
|
|
className="border-input bg-background h-7 rounded border px-2 text-xs"
|
|
>
|
|
<option value="AND">AND (모두 만족)</option>
|
|
<option value="OR">OR (하나라도 만족)</option>
|
|
</select>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeFilterGroup(group.id)}
|
|
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{group.conditions.map((condition) => (
|
|
<div key={condition.id} className="flex items-center gap-2">
|
|
{/* 컬럼 선택 */}
|
|
<select
|
|
value={condition.column}
|
|
onChange={(e) => updateFilterCondition(group.id, condition.id, "column", e.target.value)}
|
|
className="border-input bg-background h-8 w-32 rounded border px-2 text-xs sm:w-40"
|
|
>
|
|
{visibleColumns
|
|
.filter((c) => c.columnName !== "__checkbox__")
|
|
.map((col) => (
|
|
<option key={col.columnName} value={col.columnName}>
|
|
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* 연산자 선택 */}
|
|
<select
|
|
value={condition.operator}
|
|
onChange={(e) => updateFilterCondition(group.id, condition.id, "operator", e.target.value)}
|
|
className="border-input bg-background h-8 w-28 rounded border px-2 text-xs sm:w-36"
|
|
>
|
|
<option value="contains">포함</option>
|
|
<option value="notContains">포함하지 않음</option>
|
|
<option value="equals">같음</option>
|
|
<option value="notEquals">같지 않음</option>
|
|
<option value="startsWith">시작</option>
|
|
<option value="endsWith">끝</option>
|
|
<option value="greaterThan">보다 큼</option>
|
|
<option value="lessThan">보다 작음</option>
|
|
<option value="greaterOrEqual">이상</option>
|
|
<option value="lessOrEqual">이하</option>
|
|
<option value="isEmpty">비어있음</option>
|
|
<option value="isNotEmpty">비어있지 않음</option>
|
|
</select>
|
|
|
|
{/* 값 입력 (isEmpty/isNotEmpty가 아닌 경우만) */}
|
|
{condition.operator !== "isEmpty" && condition.operator !== "isNotEmpty" && (
|
|
<input
|
|
type="text"
|
|
value={condition.value}
|
|
onChange={(e) => updateFilterCondition(group.id, condition.id, "value", e.target.value)}
|
|
placeholder="값 입력"
|
|
className="border-input bg-background h-8 flex-1 rounded border px-2 text-xs"
|
|
/>
|
|
)}
|
|
|
|
{/* 조건 삭제 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeFilterCondition(group.id, condition.id)}
|
|
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
|
disabled={group.conditions.length === 1}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 조건 추가 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => addFilterCondition(group.id, visibleColumns[0]?.columnName)}
|
|
className="mt-2 h-7 text-xs"
|
|
>
|
|
+ 조건 추가
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
{/* 그룹 추가 버튼 */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => addFilterGroup(visibleColumns[0]?.columnName)}
|
|
className="w-full"
|
|
>
|
|
+ 필터 그룹 추가
|
|
</Button>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={clearFilterBuilder}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
초기화
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsFilterBuilderOpen(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button onClick={applyFilterBuilder} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
적용
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 테이블 옵션 모달 */}
|
|
<TableOptionsModal
|
|
isOpen={isTableOptionsOpen}
|
|
onClose={() => setIsTableOptionsOpen(false)}
|
|
columns={visibleColumns.map((col) => ({
|
|
columnName: col.columnName,
|
|
label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
|
visible: col.visible !== false,
|
|
width: columnWidths[col.columnName],
|
|
frozen: frozenColumns.includes(col.columnName),
|
|
}))}
|
|
onSave={handleTableOptionsSave}
|
|
tableName={tableConfig.selectedTable || "table"}
|
|
userId={userId}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
|
|
return (
|
|
<V2ErrorBoundary
|
|
componentId={props.component?.id || "table-list"}
|
|
componentType="v2-table-list"
|
|
fallbackStyle="compact"
|
|
>
|
|
<TableListComponent {...props} />
|
|
</V2ErrorBoundary>
|
|
);
|
|
};
|