Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs
2025-11-05 16:38:30 +09:00
100 changed files with 15433 additions and 790 deletions

View 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: [],
},
};
}
}

View File

@@ -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])];

View File

@@ -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,

View File

@@ -49,6 +49,7 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
value: undefined, // 미리보기이므로 값은 없음
readonly: readonly,
isDesignMode: true, // 디자인 모드임을 명시
...props, // 모든 추가 props 전달 (sortBy, sortOrder 등)
}}
config={widget.webTypeConfig}
/>

View File

@@ -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}

View File

@@ -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__" ? (

View File

@@ -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}
/>
</>
);
};

View File

@@ -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: "코드 병합 중 오류가 발생했습니다.",
},
};