Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
45
frontend/lib/api/tableSchema.ts
Normal file
45
frontend/lib/api/tableSchema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface TableColumn {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
default: string | null;
|
||||
maxLength: number | null;
|
||||
precision: number | null;
|
||||
scale: number | null;
|
||||
}
|
||||
|
||||
export interface TableSchemaResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
tableName: string;
|
||||
columns: TableColumn[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
||||
*/
|
||||
export async function getTableSchema(
|
||||
tableName: string
|
||||
): Promise<TableSchemaResponse> {
|
||||
try {
|
||||
const response = await apiClient.get<TableSchemaResponse>(
|
||||
`/admin/tables/${tableName}/schema`
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("테이블 스키마 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "테이블 스키마 조회 실패",
|
||||
data: {
|
||||
tableName,
|
||||
columns: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
||||
const preloadCommonCodesOnMount = useCallback(async (): Promise<void> => {
|
||||
if (!preloadCommonCodes) return;
|
||||
|
||||
console.log("🚀 공통 코드 프리로딩 시작");
|
||||
// console.log("🚀 공통 코드 프리로딩 시작");
|
||||
|
||||
// 현재 테이블의 코드 카테고리와 공통 카테고리 합치기
|
||||
const allCategories = [...new Set([...codeCategories, ...commonCodeCategories])];
|
||||
|
||||
@@ -29,7 +29,11 @@ export interface ComponentRenderer {
|
||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
tableDisplayData?: any[]; // 🆕 화면 표시 데이터
|
||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||
flowSelectedData?: any[];
|
||||
flowSelectedStepId?: number | null;
|
||||
@@ -103,7 +107,12 @@ export interface DynamicComponentRendererProps {
|
||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
columnOrder?: string[];
|
||||
tableDisplayData?: any[]; // 🆕 화면 표시 데이터
|
||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||
flowSelectedData?: any[];
|
||||
flowSelectedStepId?: number | null;
|
||||
@@ -195,6 +204,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
onSelectedRowsChange,
|
||||
sortBy, // 🆕 정렬 컬럼
|
||||
sortOrder, // 🆕 정렬 방향
|
||||
tableDisplayData, // 🆕 화면 표시 데이터
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
onFlowSelectedDataChange,
|
||||
@@ -284,6 +296,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
onSelectedRowsChange,
|
||||
// 테이블 정렬 정보 전달
|
||||
sortBy,
|
||||
sortOrder,
|
||||
tableDisplayData, // 🆕 화면 표시 데이터
|
||||
// 플로우 선택된 데이터 정보 전달
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
|
||||
@@ -49,6 +49,7 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||
value: undefined, // 미리보기이므로 값은 없음
|
||||
readonly: readonly,
|
||||
isDesignMode: true, // 디자인 모드임을 명시
|
||||
...props, // 모든 추가 props 전달 (sortBy, sortOrder 등)
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: any[];
|
||||
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
columnOrder?: string[];
|
||||
tableDisplayData?: any[]; // 화면에 표시된 데이터 (컬럼 순서 포함)
|
||||
|
||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||
flowSelectedData?: any[];
|
||||
@@ -74,6 +80,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
onRefresh,
|
||||
onClose,
|
||||
onFlowRefresh,
|
||||
sortBy, // 🆕 정렬 컬럼
|
||||
sortOrder, // 🆕 정렬 방향
|
||||
columnOrder, // 🆕 컬럼 순서
|
||||
tableDisplayData, // 🆕 화면에 표시된 데이터
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
flowSelectedData,
|
||||
@@ -405,6 +415,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
// 테이블 선택된 행 정보 추가
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
// 테이블 정렬 정보 추가
|
||||
sortBy, // 🆕 정렬 컬럼
|
||||
sortOrder, // 🆕 정렬 방향
|
||||
columnOrder, // 🆕 컬럼 순서
|
||||
tableDisplayData, // 🆕 화면에 표시된 데이터
|
||||
// 플로우 선택된 데이터 정보 추가
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
@@ -556,8 +571,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
margin: "0",
|
||||
lineHeight: "1.25",
|
||||
boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`,
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용 (width/height 제외)
|
||||
...(isInteractive && component.style ? Object.fromEntries(
|
||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
||||
) : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
|
||||
@@ -8,41 +8,53 @@ import { cn } from "@/lib/utils";
|
||||
import { ColumnConfig } from "./types";
|
||||
|
||||
interface SingleTableWithStickyProps {
|
||||
visibleColumns: ColumnConfig[];
|
||||
visibleColumns?: ColumnConfig[];
|
||||
columns?: ColumnConfig[];
|
||||
data: Record<string, any>[];
|
||||
columnLabels: Record<string, string>;
|
||||
sortColumn: string | null;
|
||||
sortDirection: "asc" | "desc";
|
||||
tableConfig: any;
|
||||
isDesignMode: boolean;
|
||||
isAllSelected: boolean;
|
||||
handleSort: (columnName: string) => void;
|
||||
handleSelectAll: (checked: boolean) => void;
|
||||
handleRowClick: (row: any) => void;
|
||||
renderCheckboxCell: (row: any, index: number) => React.ReactNode;
|
||||
tableConfig?: any;
|
||||
isDesignMode?: boolean;
|
||||
isAllSelected?: boolean;
|
||||
handleSort?: (columnName: string) => void;
|
||||
onSort?: (columnName: string) => void;
|
||||
handleSelectAll?: (checked: boolean) => void;
|
||||
handleRowClick?: (row: any) => void;
|
||||
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
|
||||
renderCheckboxHeader?: () => React.ReactNode;
|
||||
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
|
||||
getColumnWidth: (column: ColumnConfig) => number;
|
||||
containerWidth?: string; // 컨테이너 너비 설정
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
visibleColumns,
|
||||
columns,
|
||||
data,
|
||||
columnLabels,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
tableConfig,
|
||||
isDesignMode,
|
||||
isAllSelected,
|
||||
isDesignMode = false,
|
||||
isAllSelected = false,
|
||||
handleSort,
|
||||
onSort,
|
||||
handleSelectAll,
|
||||
handleRowClick,
|
||||
renderCheckboxCell,
|
||||
renderCheckboxHeader,
|
||||
formatCellValue,
|
||||
getColumnWidth,
|
||||
containerWidth,
|
||||
loading = false,
|
||||
error = null,
|
||||
}) => {
|
||||
const checkboxConfig = tableConfig.checkbox || {};
|
||||
const checkboxConfig = tableConfig?.checkbox || {};
|
||||
const actualColumns = visibleColumns || columns || [];
|
||||
const sortHandler = onSort || handleSort || (() => {});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -71,15 +83,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
}
|
||||
>
|
||||
<TableRow className="border-b">
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
{actualColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = visibleColumns
|
||||
const leftFixedWidth = actualColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||
|
||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||
const rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
@@ -115,7 +127,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||
onClick={() => column.sortable && sortHandler(column.columnName)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { tableDisplayStore } from "@/stores/tableDisplayStore";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -37,6 +38,7 @@ 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";
|
||||
|
||||
// ========================================
|
||||
// 인터페이스
|
||||
@@ -138,7 +140,8 @@ export interface TableListComponentProps {
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
screenId?: string;
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
||||
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||
onConfigChange?: (config: any) => void;
|
||||
refreshKey?: number;
|
||||
}
|
||||
@@ -163,6 +166,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
onConfigChange,
|
||||
refreshKey,
|
||||
tableName,
|
||||
userId,
|
||||
}) => {
|
||||
// ========================================
|
||||
// 설정 및 스타일
|
||||
@@ -178,18 +182,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
let finalSelectedTable =
|
||||
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
|
||||
|
||||
console.log("🔍 TableListComponent 초기화:", {
|
||||
componentConfigSelectedTable: componentConfig?.selectedTable,
|
||||
componentConfigSelectedTableType: typeof componentConfig?.selectedTable,
|
||||
componentConfigSelectedTable2: component.config?.selectedTable,
|
||||
componentConfigSelectedTable2Type: typeof component.config?.selectedTable,
|
||||
configSelectedTable: config?.selectedTable,
|
||||
configSelectedTableType: typeof config?.selectedTable,
|
||||
screenTableName: tableName,
|
||||
screenTableNameType: typeof tableName,
|
||||
finalSelectedTable,
|
||||
finalSelectedTableType: typeof finalSelectedTable,
|
||||
});
|
||||
// 디버그 로그 제거 (성능 최적화)
|
||||
|
||||
// 객체인 경우 tableName 속성 추출 시도
|
||||
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
|
||||
@@ -200,12 +193,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
|
||||
tableConfig.selectedTable = finalSelectedTable;
|
||||
|
||||
console.log(
|
||||
"✅ 최종 tableConfig.selectedTable:",
|
||||
tableConfig.selectedTable,
|
||||
"타입:",
|
||||
typeof tableConfig.selectedTable,
|
||||
);
|
||||
// 디버그 로그 제거 (성능 최적화)
|
||||
|
||||
const buttonColor = component.style?.labelColor || "#212121";
|
||||
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
||||
@@ -259,9 +247,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({});
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||
const [isAllSelected, setIsAllSelected] = useState(false);
|
||||
const hasInitializedWidths = useRef(false);
|
||||
@@ -276,6 +264,68 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// 사용자 옵션 모달 관련 상태
|
||||
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
|
||||
const [showGridLines, setShowGridLines] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||
|
||||
// 🆕 초기 로드 시 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);
|
||||
console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder });
|
||||
setColumnOrder(parsedOrder);
|
||||
|
||||
// 부모 컴포넌트에 초기 컬럼 순서 전달
|
||||
if (onSelectedRowsChange && parsedOrder.length > 0) {
|
||||
console.log("✅ 초기 컬럼 순서 전달:", parsedOrder);
|
||||
|
||||
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
|
||||
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;
|
||||
});
|
||||
|
||||
console.log("📊 초기 화면 표시 데이터 전달:", { count: initialData.length, firstRow: initialData[0] });
|
||||
|
||||
// 전역 저장소에 데이터 저장
|
||||
if (tableConfig.selectedTable) {
|
||||
tableDisplayStore.setTableData(
|
||||
tableConfig.selectedTable,
|
||||
initialData,
|
||||
parsedOrder.filter(col => col !== '__checkbox__'),
|
||||
sortColumn,
|
||||
sortDirection
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -390,10 +440,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블명 확인 로그
|
||||
console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
|
||||
console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
|
||||
console.log("🔍 전체 tableConfig:", tableConfig);
|
||||
// 테이블명 확인 로그 (개발 시에만)
|
||||
// console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
|
||||
// console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
|
||||
// console.log("🔍 전체 tableConfig:", tableConfig);
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -488,11 +538,105 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
};
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection });
|
||||
|
||||
let newSortColumn = column;
|
||||
let newSortDirection: "asc" | "desc" = "asc";
|
||||
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
newSortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||
setSortDirection(newSortDirection);
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection("asc");
|
||||
newSortColumn = column;
|
||||
newSortDirection = "asc";
|
||||
}
|
||||
|
||||
console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection });
|
||||
console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange);
|
||||
|
||||
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
|
||||
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단계: 정렬된 데이터를 컬럼 순서대로 재정렬
|
||||
const reorderedData = sortedData.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;
|
||||
});
|
||||
|
||||
console.log("✅ 정렬 정보 전달:", {
|
||||
selectedRowsCount: selectedRows.size,
|
||||
selectedRowsDataCount: selectedRowsData.length,
|
||||
sortBy: newSortColumn,
|
||||
sortOrder: newSortDirection,
|
||||
columnOrder: columnOrder.length > 0 ? columnOrder : undefined,
|
||||
tableDisplayDataCount: reorderedData.length,
|
||||
firstRowAfterSort: reorderedData[0]?.[newSortColumn],
|
||||
lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn]
|
||||
});
|
||||
onSelectedRowsChange(
|
||||
Array.from(selectedRows),
|
||||
selectedRowsData,
|
||||
newSortColumn,
|
||||
newSortDirection,
|
||||
columnOrder.length > 0 ? columnOrder : undefined,
|
||||
reorderedData
|
||||
);
|
||||
|
||||
// 전역 저장소에 정렬된 데이터 저장
|
||||
if (tableConfig.selectedTable) {
|
||||
const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : visibleColumns.map(c => c.columnName)).filter(col => col !== '__checkbox__');
|
||||
tableDisplayStore.setTableData(
|
||||
tableConfig.selectedTable,
|
||||
reorderedData,
|
||||
cleanColumnOrder,
|
||||
newSortColumn,
|
||||
newSortDirection
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -530,7 +674,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
|
||||
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({
|
||||
@@ -551,7 +695,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
setIsAllSelected(true);
|
||||
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), data);
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({
|
||||
@@ -564,7 +708,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
setIsAllSelected(false);
|
||||
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange([], []);
|
||||
onSelectedRowsChange([], [], sortColumn || undefined, sortDirection);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
||||
@@ -572,21 +716,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (row: any) => {
|
||||
console.log("행 클릭:", row);
|
||||
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
|
||||
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('input[type="checkbox"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 행 선택/해제 토글
|
||||
const rowKey = getRowKey(row, index);
|
||||
const isCurrentlySelected = selectedRows.has(rowKey);
|
||||
|
||||
handleRowSelection(rowKey, !isCurrentlySelected);
|
||||
|
||||
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
|
||||
};
|
||||
|
||||
const handleRowDragStart = (e: React.DragEvent, row: any, index: number) => {
|
||||
setIsDragging(true);
|
||||
setDraggedRowIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(row));
|
||||
};
|
||||
|
||||
const handleRowDragEnd = (e: React.DragEvent) => {
|
||||
setIsDragging(false);
|
||||
setDraggedRowIndex(null);
|
||||
};
|
||||
// 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능)
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -619,8 +765,98 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// columnOrder 상태가 있으면 그 순서대로 정렬
|
||||
if (columnOrder.length > 0) {
|
||||
const orderedCols = columnOrder
|
||||
.map(colName => cols.find(c => c.columnName === colName))
|
||||
.filter(Boolean) as ColumnConfig[];
|
||||
|
||||
// columnOrder에 없는 새로운 컬럼들 추가
|
||||
const remainingCols = cols.filter(c => !columnOrder.includes(c.columnName));
|
||||
|
||||
console.log("🔄 columnOrder 기반 정렬:", {
|
||||
columnOrder,
|
||||
orderedColsCount: orderedCols.length,
|
||||
remainingColsCount: remainingCols.length
|
||||
});
|
||||
|
||||
return [...orderedCols, ...remainingCols];
|
||||
}
|
||||
|
||||
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
}, [tableConfig.columns, tableConfig.checkbox]);
|
||||
}, [tableConfig.columns, tableConfig.checkbox, columnOrder]);
|
||||
|
||||
// 🆕 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(",")]); // 의존성 단순화
|
||||
|
||||
const getColumnWidth = (column: ColumnConfig) => {
|
||||
if (column.columnName === "__checkbox__") return 50;
|
||||
@@ -861,6 +1097,48 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 사용자 옵션 저장 핸들러
|
||||
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) => {
|
||||
@@ -1120,6 +1398,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsTableOptionsOpen(true)}
|
||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||
>
|
||||
<TableIcon className="mr-2 h-4 w-4" />
|
||||
테이블 옵션
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1178,6 +1465,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
tableConfig={tableConfig}
|
||||
isDesignMode={isDesignMode}
|
||||
isAllSelected={isAllSelected}
|
||||
handleSelectAll={handleSelectAll}
|
||||
handleRowClick={handleRowClick}
|
||||
columnLabels={columnLabels}
|
||||
renderCheckboxHeader={renderCheckboxHeader}
|
||||
renderCheckboxCell={renderCheckboxCell}
|
||||
@@ -1213,6 +1505,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsTableOptionsOpen(true)}
|
||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||
>
|
||||
<TableIcon className="mr-2 h-4 w-4" />
|
||||
테이블 옵션
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1273,7 +1574,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
>
|
||||
{/* 테이블 */}
|
||||
<table
|
||||
className="w-full max-w-full table-mobile-fixed"
|
||||
className={cn(
|
||||
"w-full max-w-full table-mobile-fixed",
|
||||
!showGridLines && "hide-grid"
|
||||
)}
|
||||
style={{
|
||||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
@@ -1287,26 +1591,42 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
<tr className="h-10 border-b-2 border-primary/20 bg-gradient-to-b from-muted/50 to-muted sm:h-12">
|
||||
{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];
|
||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<th
|
||||
key={column.columnName}
|
||||
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
||||
className={cn(
|
||||
"relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3",
|
||||
column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors"
|
||||
"relative h-8 text-xs font-bold text-foreground/90 overflow-hidden 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__") && "cursor-pointer hover:bg-muted/70 transition-colors",
|
||||
isFrozen && "sticky z-20 bg-muted/80 backdrop-blur-sm shadow-[2px_0_4px_rgba(0,0,0,0.1)]"
|
||||
)}
|
||||
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'
|
||||
userSelect: 'none',
|
||||
...(isFrozen && { left: `${leftPosition}px` })
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isResizing.current) return;
|
||||
if (column.sortable) handleSort(column.columnName);
|
||||
if (column.sortable !== false && column.columnName !== "__checkbox__") {
|
||||
handleSort(column.columnName);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
@@ -1314,7 +1634,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||
{column.sortable && sortColumn === column.columnName && (
|
||||
{column.sortable !== false && sortColumn === column.columnName && (
|
||||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1448,15 +1768,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
group.items.map((row, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
draggable={!isDesignMode}
|
||||
onDragStart={(e) => handleRowDragStart(e, row, index)}
|
||||
onDragEnd={handleRowDragEnd}
|
||||
className={cn(
|
||||
"h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16"
|
||||
"h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12"
|
||||
)}
|
||||
onClick={() => handleRowClick(row)}
|
||||
onClick={(e) => handleRowClick(row, index, e)}
|
||||
>
|
||||
{visibleColumns.map((column) => {
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||||
const cellValue = row[mappedColumnName];
|
||||
|
||||
@@ -1464,18 +1781,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
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];
|
||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
|
||||
"h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
||||
isFrozen && "sticky z-10 bg-background shadow-[2px_0_4px_rgba(0,0,0,0.05)]"
|
||||
)}
|
||||
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` })
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
@@ -1494,15 +1826,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
data.map((row, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
draggable={!isDesignMode}
|
||||
onDragStart={(e) => handleRowDragStart(e, row, index)}
|
||||
onDragEnd={handleRowDragEnd}
|
||||
className={cn(
|
||||
"h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16"
|
||||
"h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12"
|
||||
)}
|
||||
onClick={() => handleRowClick(row)}
|
||||
onClick={(e) => handleRowClick(row, index, e)}
|
||||
>
|
||||
{visibleColumns.map((column) => {
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||||
const cellValue = row[mappedColumnName];
|
||||
|
||||
@@ -1510,18 +1839,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
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];
|
||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
|
||||
"h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
||||
isFrozen && "sticky z-10 bg-background shadow-[2px_0_4px_rgba(0,0,0,0.05)]"
|
||||
)}
|
||||
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` })
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
@@ -1683,6 +2027,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,8 @@ export type ButtonActionType =
|
||||
| "view_table_history" // 테이블 이력 보기
|
||||
| "excel_download" // 엑셀 다운로드
|
||||
| "excel_upload" // 엑셀 업로드
|
||||
| "barcode_scan"; // 바코드 스캔
|
||||
| "barcode_scan" // 바코드 스캔
|
||||
| "code_merge"; // 코드 병합
|
||||
|
||||
/**
|
||||
* 버튼 액션 설정
|
||||
@@ -73,6 +74,10 @@ export interface ButtonActionConfig {
|
||||
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
|
||||
barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all")
|
||||
barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부
|
||||
|
||||
// 코드 병합 관련
|
||||
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
|
||||
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,8 +106,12 @@ export interface ButtonActionContext {
|
||||
|
||||
// 제어 실행을 위한 추가 정보
|
||||
buttonId?: string;
|
||||
userId?: string;
|
||||
companyCode?: string;
|
||||
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string; // 정렬 컬럼명
|
||||
sortOrder?: "asc" | "desc"; // 정렬 방향
|
||||
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
|
||||
tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +156,9 @@ export class ButtonActionExecutor {
|
||||
case "barcode_scan":
|
||||
return await this.handleBarcodeScan(config, context);
|
||||
|
||||
case "code_merge":
|
||||
return await this.handleCodeMerge(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
@@ -1729,6 +1741,17 @@ export class ButtonActionExecutor {
|
||||
private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📥 엑셀 다운로드 시작:", { config, context });
|
||||
console.log("🔍 context.columnOrder 확인:", {
|
||||
hasColumnOrder: !!context.columnOrder,
|
||||
columnOrderLength: context.columnOrder?.length,
|
||||
columnOrder: context.columnOrder,
|
||||
});
|
||||
console.log("🔍 context.tableDisplayData 확인:", {
|
||||
hasTableDisplayData: !!context.tableDisplayData,
|
||||
tableDisplayDataLength: context.tableDisplayData?.length,
|
||||
tableDisplayDataFirstRow: context.tableDisplayData?.[0],
|
||||
tableDisplayDataColumns: context.tableDisplayData?.[0] ? Object.keys(context.tableDisplayData[0]) : [],
|
||||
});
|
||||
|
||||
// 동적 import로 엑셀 유틸리티 로드
|
||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||
@@ -1739,17 +1762,93 @@ export class ButtonActionExecutor {
|
||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
dataToExport = context.selectedRowsData;
|
||||
console.log("✅ 선택된 행 데이터 사용:", dataToExport.length);
|
||||
|
||||
// 선택된 행도 정렬 적용
|
||||
if (context.sortBy) {
|
||||
console.log("🔄 선택된 행 데이터 정렬 적용:", {
|
||||
sortBy: context.sortBy,
|
||||
sortOrder: context.sortOrder,
|
||||
});
|
||||
|
||||
dataToExport = [...dataToExport].sort((a, b) => {
|
||||
const aVal = a[context.sortBy!];
|
||||
const bVal = b[context.sortBy!];
|
||||
|
||||
// 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 context.sortOrder === "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 context.sortOrder === "desc" ? -comparison : comparison;
|
||||
});
|
||||
|
||||
console.log("✅ 정렬 완료:", {
|
||||
firstRow: dataToExport[0],
|
||||
lastRow: dataToExport[dataToExport.length - 1],
|
||||
firstSortValue: dataToExport[0]?.[context.sortBy],
|
||||
lastSortValue: dataToExport[dataToExport.length - 1]?.[context.sortBy],
|
||||
});
|
||||
}
|
||||
}
|
||||
// 2순위: 테이블 전체 데이터 (API 호출)
|
||||
// 2순위: 화면 표시 데이터 (컬럼 순서 포함, 정렬 적용됨)
|
||||
else if (context.tableDisplayData && context.tableDisplayData.length > 0) {
|
||||
dataToExport = context.tableDisplayData;
|
||||
console.log("✅ 화면 표시 데이터 사용 (context):", {
|
||||
count: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
columns: Object.keys(dataToExport[0] || {}),
|
||||
});
|
||||
}
|
||||
// 2.5순위: 전역 저장소에서 화면 표시 데이터 조회
|
||||
else if (context.tableName) {
|
||||
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
|
||||
const storedData = tableDisplayStore.getTableData(context.tableName);
|
||||
|
||||
if (storedData && storedData.data.length > 0) {
|
||||
dataToExport = storedData.data;
|
||||
console.log("✅ 화면 표시 데이터 사용 (전역 저장소):", {
|
||||
tableName: context.tableName,
|
||||
count: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
lastRow: dataToExport[dataToExport.length - 1],
|
||||
columns: Object.keys(dataToExport[0] || {}),
|
||||
columnOrder: storedData.columnOrder,
|
||||
sortBy: storedData.sortBy,
|
||||
sortOrder: storedData.sortOrder,
|
||||
// 정렬 컬럼의 첫/마지막 값 확인
|
||||
firstSortValue: storedData.sortBy ? dataToExport[0]?.[storedData.sortBy] : undefined,
|
||||
lastSortValue: storedData.sortBy ? dataToExport[dataToExport.length - 1]?.[storedData.sortBy] : undefined,
|
||||
});
|
||||
}
|
||||
// 3순위: 테이블 전체 데이터 (API 호출)
|
||||
else {
|
||||
console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName);
|
||||
console.log("📊 정렬 정보:", {
|
||||
sortBy: context.sortBy,
|
||||
sortOrder: context.sortOrder,
|
||||
});
|
||||
try {
|
||||
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||
const response = await dynamicFormApi.getTableData(context.tableName, {
|
||||
page: 1,
|
||||
pageSize: 10000, // 최대 10,000개 행
|
||||
sortBy: "id", // 기본 정렬: id 컬럼
|
||||
sortOrder: "asc", // 오름차순
|
||||
sortBy: context.sortBy || "id", // 화면 정렬 또는 기본 정렬
|
||||
sortOrder: context.sortOrder || "asc", // 화면 정렬 방향 또는 오름차순
|
||||
});
|
||||
|
||||
console.log("📦 API 응답 구조:", {
|
||||
@@ -1773,6 +1872,7 @@ export class ButtonActionExecutor {
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4순위: 폼 데이터
|
||||
else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
@@ -1814,12 +1914,60 @@ export class ButtonActionExecutor {
|
||||
const sheetName = config.excelSheetName || "Sheet1";
|
||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||
|
||||
// 🆕 컬럼 순서 재정렬 (화면에 표시된 순서대로)
|
||||
let columnOrder: string[] | undefined = context.columnOrder;
|
||||
|
||||
// columnOrder가 없으면 tableDisplayData에서 추출 시도
|
||||
if (!columnOrder && context.tableDisplayData && context.tableDisplayData.length > 0) {
|
||||
columnOrder = Object.keys(context.tableDisplayData[0]);
|
||||
console.log("📊 tableDisplayData에서 컬럼 순서 추출:", columnOrder);
|
||||
}
|
||||
|
||||
if (columnOrder && columnOrder.length > 0 && dataToExport.length > 0) {
|
||||
console.log("🔄 컬럼 순서 재정렬 시작:", {
|
||||
columnOrder,
|
||||
originalColumns: Object.keys(dataToExport[0] || {}),
|
||||
});
|
||||
|
||||
dataToExport = dataToExport.map((row: any) => {
|
||||
const reorderedRow: any = {};
|
||||
|
||||
// 1. columnOrder에 있는 컬럼들을 순서대로 추가
|
||||
columnOrder!.forEach((colName: string) => {
|
||||
if (colName in row) {
|
||||
reorderedRow[colName] = row[colName];
|
||||
}
|
||||
});
|
||||
|
||||
// 2. columnOrder에 없는 나머지 컬럼들 추가 (끝에 배치)
|
||||
Object.keys(row).forEach((key) => {
|
||||
if (!(key in reorderedRow)) {
|
||||
reorderedRow[key] = row[key];
|
||||
}
|
||||
});
|
||||
|
||||
return reorderedRow;
|
||||
});
|
||||
|
||||
console.log("✅ 컬럼 순서 재정렬 완료:", {
|
||||
reorderedColumns: Object.keys(dataToExport[0] || {}),
|
||||
});
|
||||
} else {
|
||||
console.log("⏭️ 컬럼 순서 재정렬 스킵:", {
|
||||
hasColumnOrder: !!columnOrder,
|
||||
columnOrderLength: columnOrder?.length,
|
||||
hasTableDisplayData: !!context.tableDisplayData,
|
||||
dataToExportLength: dataToExport.length,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("📥 엑셀 다운로드 실행:", {
|
||||
fileName,
|
||||
sheetName,
|
||||
includeHeaders,
|
||||
dataCount: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
columnOrder: context.columnOrder,
|
||||
});
|
||||
|
||||
// 엑셀 다운로드 실행
|
||||
@@ -1865,6 +2013,7 @@ export class ButtonActionExecutor {
|
||||
tableName: context.tableName || "",
|
||||
uploadMode: config.excelUploadMode || "insert",
|
||||
keyColumn: config.excelKeyColumn,
|
||||
userId: context.userId,
|
||||
onSuccess: () => {
|
||||
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
||||
context.onRefresh?.();
|
||||
@@ -1912,6 +2061,7 @@ export class ButtonActionExecutor {
|
||||
targetField: config.barcodeTargetField,
|
||||
barcodeFormat: config.barcodeFormat || "all",
|
||||
autoSubmit: config.barcodeAutoSubmit || false,
|
||||
userId: context.userId,
|
||||
onScanSuccess: (barcode: string) => {
|
||||
console.log("✅ 바코드 스캔 성공:", barcode);
|
||||
|
||||
@@ -1943,6 +2093,177 @@ export class ButtonActionExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 병합 액션 처리
|
||||
*/
|
||||
private static async handleCodeMerge(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("🔀 코드 병합 액션 실행:", { config, context });
|
||||
|
||||
// 선택된 행 데이터 확인
|
||||
const selectedRows = context.selectedRowsData || context.flowSelectedData;
|
||||
if (!selectedRows || selectedRows.length !== 2) {
|
||||
toast.error("병합할 두 개의 항목을 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 병합할 컬럼명 확인
|
||||
const columnName = config.mergeColumnName;
|
||||
if (!columnName) {
|
||||
toast.error("병합할 컬럼명이 설정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 두 개의 선택된 행에서 컬럼 값 추출
|
||||
const [row1, row2] = selectedRows;
|
||||
const value1 = row1[columnName];
|
||||
const value2 = row2[columnName];
|
||||
|
||||
if (!value1 || !value2) {
|
||||
toast.error(`선택한 항목에 "${columnName}" 값이 없습니다.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value1 === value2) {
|
||||
toast.error("같은 값은 병합할 수 없습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 병합 방향 선택 모달 표시
|
||||
const confirmed = await new Promise<{ confirmed: boolean; oldValue: string; newValue: string }>((resolve) => {
|
||||
const modalHtml = `
|
||||
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999;">
|
||||
<div style="background: white; padding: 24px; border-radius: 8px; max-width: 500px; width: 90%;">
|
||||
<h3 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600;">코드 병합 방향 선택</h3>
|
||||
<p style="margin: 0 0 24px 0; color: #666;">어느 코드로 병합하시겠습니까?</p>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 24px;">
|
||||
<button id="merge-option-1" style="flex: 1; padding: 16px; border: 2px solid #e5e7eb; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">${value1}</div>
|
||||
<div style="font-size: 12px; color: #666;">← ${value2} 병합</div>
|
||||
</button>
|
||||
|
||||
<button id="merge-option-2" style="flex: 1; padding: 16px; border: 2px solid #e5e7eb; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">${value2}</div>
|
||||
<div style="font-size: 12px; color: #666;">← ${value1} 병합</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button id="merge-cancel" style="padding: 8px 16px; border: 1px solid #e5e7eb; border-radius: 6px; background: white; cursor: pointer;">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modalContainer = document.createElement("div");
|
||||
modalContainer.innerHTML = modalHtml;
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
const option1Btn = modalContainer.querySelector("#merge-option-1") as HTMLButtonElement;
|
||||
const option2Btn = modalContainer.querySelector("#merge-option-2") as HTMLButtonElement;
|
||||
const cancelBtn = modalContainer.querySelector("#merge-cancel") as HTMLButtonElement;
|
||||
|
||||
// 호버 효과
|
||||
[option1Btn, option2Btn].forEach((btn) => {
|
||||
btn.addEventListener("mouseenter", () => {
|
||||
btn.style.borderColor = "#3b82f6";
|
||||
btn.style.background = "#eff6ff";
|
||||
});
|
||||
btn.addEventListener("mouseleave", () => {
|
||||
btn.style.borderColor = "#e5e7eb";
|
||||
btn.style.background = "white";
|
||||
});
|
||||
});
|
||||
|
||||
option1Btn.addEventListener("click", () => {
|
||||
document.body.removeChild(modalContainer);
|
||||
resolve({ confirmed: true, oldValue: value2, newValue: value1 });
|
||||
});
|
||||
|
||||
option2Btn.addEventListener("click", () => {
|
||||
document.body.removeChild(modalContainer);
|
||||
resolve({ confirmed: true, oldValue: value1, newValue: value2 });
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
document.body.removeChild(modalContainer);
|
||||
resolve({ confirmed: false, oldValue: "", newValue: "" });
|
||||
});
|
||||
});
|
||||
|
||||
if (!confirmed.confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { oldValue, newValue } = confirmed;
|
||||
|
||||
// 미리보기 표시 (옵션)
|
||||
if (config.mergeShowPreview !== false) {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
const previewResponse = await apiClient.post("/code-merge/preview", {
|
||||
columnName,
|
||||
oldValue,
|
||||
});
|
||||
|
||||
if (previewResponse.data.success) {
|
||||
const preview = previewResponse.data.data;
|
||||
const totalRows = preview.totalAffectedRows;
|
||||
|
||||
const confirmMerge = confirm(
|
||||
`⚠️ 코드 병합 확인\n\n` +
|
||||
`${oldValue} → ${newValue}\n\n` +
|
||||
`영향받는 데이터:\n` +
|
||||
`- 테이블 수: ${preview.preview.length}개\n` +
|
||||
`- 총 행 수: ${totalRows}개\n\n` +
|
||||
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
|
||||
`계속하시겠습니까?`
|
||||
);
|
||||
|
||||
if (!confirmMerge) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 병합 실행
|
||||
toast.loading("코드 병합 중...", { duration: Infinity });
|
||||
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
const response = await apiClient.post("/code-merge/merge-all-tables", {
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
|
||||
toast.dismiss();
|
||||
|
||||
if (response.data.success) {
|
||||
const data = response.data.data;
|
||||
toast.success(
|
||||
`코드 병합 완료!\n` +
|
||||
`${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`
|
||||
);
|
||||
|
||||
// 화면 새로고침
|
||||
context.onRefresh?.();
|
||||
context.onFlowRefresh?.();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
toast.error(response.data.message || "코드 병합에 실패했습니다.");
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ 코드 병합 실패:", error);
|
||||
toast.dismiss();
|
||||
toast.error(error.response?.data?.message || "코드 병합 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 유효성 검사
|
||||
*/
|
||||
@@ -2032,4 +2353,11 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
||||
barcodeFormat: "all",
|
||||
barcodeAutoSubmit: false,
|
||||
},
|
||||
code_merge: {
|
||||
type: "code_merge",
|
||||
mergeShowPreview: true,
|
||||
confirmMessage: "선택한 두 항목을 병합하시겠습니까?",
|
||||
successMessage: "코드 병합이 완료되었습니다.",
|
||||
errorMessage: "코드 병합 중 오류가 발생했습니다.",
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user