diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 10de1e73..608f8b96 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2980,20 +2980,20 @@ export class TableManagementService { try { logger.info(`컬럼 입력타입 정보 조회: ${tableName}`); - // table_type_columns에서 입력타입 정보 조회 + // column_labels에서 입력타입 정보 조회 const rawInputTypes = await query( `SELECT - ttc.column_name as "columnName", - ttc.column_name as "displayName", - COALESCE(ttc.input_type, 'text') as "inputType", - COALESCE(ttc.detail_settings, '{}') as "detailSettings", - ttc.is_nullable as "isNullable", + cl.column_name as "columnName", + cl.column_label as "displayName", + COALESCE(cl.input_type, 'text') as "inputType", + '{}'::jsonb as "detailSettings", + ic.is_nullable as "isNullable", ic.data_type as "dataType" - FROM table_type_columns ttc + FROM column_labels cl LEFT JOIN information_schema.columns ic - ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name - WHERE ttc.table_name = $1 - ORDER BY ttc.display_order, ttc.column_name`, + ON cl.table_name = ic.table_name AND cl.column_name = ic.column_name + WHERE cl.table_name = $1 + ORDER BY cl.column_name`, [tableName] ); diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index f266218a..d837d355 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -116,6 +116,11 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) { setFlowName(e.target.value)} + onKeyDown={(e) => { + // 입력 필드에서 키 이벤트가 FlowEditor로 전파되지 않도록 방지 + // FlowEditor의 Backspace/Delete 키로 노드가 삭제되는 것을 막음 + e.stopPropagation(); + }} className="h-8 w-[200px] text-sm" placeholder="플로우 이름" /> diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 2d3fb513..76363e4f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -102,13 +102,6 @@ export const EditModal: React.FC = ({ className }) => { useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { const { screenId, title, description, modalSize, editData, onSave } = event.detail; - console.log("🚀 EditModal 열기 이벤트 수신:", { - screenId, - title, - description, - modalSize, - editData, - }); setModalState({ isOpen: true, @@ -126,7 +119,16 @@ export const EditModal: React.FC = ({ className }) => { }; const handleCloseEditModal = () => { - console.log("🚪 EditModal 닫기 이벤트 수신"); + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("⚠️ onSave 콜백 에러:", callbackError); + } + } + + // 모달 닫기 handleClose(); }; @@ -137,7 +139,7 @@ export const EditModal: React.FC = ({ className }) => { window.removeEventListener("openEditModal", handleOpenEditModal as EventListener); window.removeEventListener("closeEditModal", handleCloseEditModal); }; - }, []); + }, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조 // 화면 데이터 로딩 useEffect(() => { @@ -211,12 +213,6 @@ export const EditModal: React.FC = ({ className }) => { } try { - console.log("💾 수정 저장 시작:", { - tableName: screenData.screenInfo.tableName, - formData, - originalData, - }); - // 변경된 필드만 추출 const changedData: Record = {}; Object.keys(formData).forEach((key) => { @@ -225,26 +221,33 @@ export const EditModal: React.FC = ({ className }) => { } }); - console.log("📝 변경된 필드:", changedData); - if (Object.keys(changedData).length === 0) { toast.info("변경된 내용이 없습니다."); handleClose(); return; } + // 기본키 확인 (id 또는 첫 번째 키) + const recordId = originalData.id || Object.values(originalData)[0]; + // UPDATE 액션 실행 - const response = await dynamicFormApi.updateData(screenData.screenInfo.tableName, { - ...originalData, // 원본 데이터 (WHERE 조건용) - ...changedData, // 변경된 데이터만 - }); + const response = await dynamicFormApi.updateFormDataPartial( + recordId, + originalData, + changedData, + screenData.screenInfo.tableName, + ); if (response.success) { toast.success("데이터가 수정되었습니다."); - // 부모 컴포넌트의 onSave 콜백 실행 + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) if (modalState.onSave) { - modalState.onSave(); + try { + modalState.onSave(); + } catch (callbackError) { + console.error("⚠️ onSave 콜백 에러:", callbackError); + } } handleClose(); @@ -335,16 +338,10 @@ export const EditModal: React.FC = ({ className }) => { allComponents={screenData.components} formData={formData} onFormDataChange={(fieldName, value) => { - console.log(`🎯 EditModal onFormDataChange 호출: ${fieldName} = "${value}"`); - console.log("📋 현재 formData:", formData); - setFormData((prev) => { - const newFormData = { - ...prev, - [fieldName]: value, - }; - console.log("📝 EditModal 업데이트된 formData:", newFormData); - return newFormData; - }); + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); }} screenInfo={{ id: modalState.screenId!, diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index b54df6ad..e05cc973 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -38,10 +38,12 @@ import { Folder, FolderOpen, Grid, + Filter, } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; import { commonCodeApi } from "@/lib/api/commonCode"; import { getCurrentUser, UserInfo } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup"; import { cn } from "@/lib/utils"; import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file"; @@ -99,6 +101,7 @@ export const InteractiveDataTable: React.FC = ({ onRefresh, }) => { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + const { user } = useAuth(); // 사용자 정보 가져오기 const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [searchValues, setSearchValues] = useState>({}); @@ -106,6 +109,10 @@ export const InteractiveDataTable: React.FC = ({ const [totalPages, setTotalPages] = useState(1); const [total, setTotal] = useState(0); const [selectedRows, setSelectedRows] = useState>(new Set()); + const [columnWidths, setColumnWidths] = useState>({}); + const hasInitializedWidthsRef = useRef(false); + const columnRefs = useRef>({}); + const isResizingRef = useRef(false); // SaveModal 상태 (등록/수정 통합) const [showSaveModal, setShowSaveModal] = useState(false); @@ -130,6 +137,13 @@ export const InteractiveDataTable: React.FC = ({ // 공통코드 관리 상태 const [codeOptions, setCodeOptions] = useState>>({}); + // 🆕 검색 필터 관련 상태 (FlowWidget과 동일) + const [searchFilterColumns, setSearchFilterColumns] = useState>(new Set()); // 검색 필터로 사용할 컬럼 + const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그 + const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 + const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 + const [columnLabels, setColumnLabels] = useState>({}); // 컬럼명 -> 라벨 매핑 + // 공통코드 옵션 가져오기 const loadCodeOptions = useCallback( async (categoryCode: string) => { @@ -408,6 +422,35 @@ export const InteractiveDataTable: React.FC = ({ // 페이지 크기 설정 const pageSize = component.pagination?.pageSize || 10; + // 초기 컬럼 너비 측정 (한 번만) + useEffect(() => { + if (!hasInitializedWidthsRef.current && visibleColumns.length > 0) { + // 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정 + const timer = setTimeout(() => { + const newWidths: Record = {}; + let hasAnyWidth = false; + + visibleColumns.forEach((column) => { + const thElement = columnRefs.current[column.id]; + if (thElement) { + const measuredWidth = thElement.offsetWidth; + if (measuredWidth > 0) { + newWidths[column.id] = measuredWidth; + hasAnyWidth = true; + } + } + }); + + if (hasAnyWidth) { + setColumnWidths(newWidths); + hasInitializedWidthsRef.current = true; + } + }, 100); + + return () => clearTimeout(timer); + } + }, [visibleColumns]); + // 데이터 로드 함수 const loadData = useCallback( async (page: number = 1, searchParams: Record = {}) => { @@ -600,6 +643,31 @@ export const InteractiveDataTable: React.FC = ({ try { const columns = await tableTypeApi.getColumns(component.tableName); setTableColumns(columns); + + // 🆕 전체 컬럼 목록 설정 + const columnNames = columns.map(col => col.columnName); + setAllAvailableColumns(columnNames); + + // 🆕 컬럼명 -> 라벨 매핑 생성 + const labels: Record = {}; + columns.forEach(col => { + labels[col.columnName] = col.displayName || col.columnName; + }); + setColumnLabels(labels); + + // 🆕 localStorage에서 필터 설정 복원 + if (user?.userId && component.componentId) { + const storageKey = `table-search-filter-${user.userId}-${component.componentId}`; + const savedFilter = localStorage.getItem(storageKey); + if (savedFilter) { + try { + const parsed = JSON.parse(savedFilter); + setSearchFilterColumns(new Set(parsed)); + } catch (e) { + console.error("필터 설정 복원 실패:", e); + } + } + } } catch (error) { // console.error("테이블 컬럼 정보 로드 실패:", error); } @@ -608,7 +676,7 @@ export const InteractiveDataTable: React.FC = ({ if (component.tableName) { fetchTableColumns(); } - }, [component.tableName]); + }, [component.tableName, component.componentId, user?.userId]); // 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함) const searchFilters = useMemo(() => { @@ -769,7 +837,7 @@ export const InteractiveDataTable: React.FC = ({ setShowSaveModal(true); }, [getDisplayColumns, generateAutoValue, component.addModalConfig]); - // 데이터 수정 핸들러 (SaveModal 사용) + // 데이터 수정 핸들러 (EditModal 사용) const handleEditData = useCallback(() => { if (selectedRows.size !== 1) return; @@ -793,17 +861,25 @@ export const InteractiveDataTable: React.FC = ({ initialData[col.columnName] = selectedRowData[col.columnName] || ""; }); - setEditFormData(initialData); - setEditingRowData(selectedRowData); - // 수정 모달 설정에서 제목과 설명 가져오기 - const editModalTitle = component.editModalConfig?.title || ""; + const editModalTitle = component.editModalConfig?.title || "데이터 수정"; const editModalDescription = component.editModalConfig?.description || ""; - console.log("📝 수정 모달 설정:", { editModalTitle, editModalDescription }); - - setShowEditModal(true); - }, [selectedRows, data, getDisplayColumns, component.editModalConfig]); + // 전역 EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId, + title: editModalTitle, + description: editModalDescription, + modalSize: "lg", + editData: initialData, + onSave: () => { + loadData(); // 테이블 데이터 새로고침 + }, + }, + }); + window.dispatchEvent(event); + }, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData]); // 수정 폼 데이터 변경 핸들러 const handleEditFormChange = useCallback((columnName: string, value: any) => { @@ -1011,6 +1087,29 @@ export const InteractiveDataTable: React.FC = ({ } }, [isAdding]); + // 🆕 검색 필터 저장 함수 + const handleSaveSearchFilter = useCallback(() => { + if (user?.userId && component.componentId) { + const storageKey = `table-search-filter-${user.userId}-${component.componentId}`; + const filterArray = Array.from(searchFilterColumns); + localStorage.setItem(storageKey, JSON.stringify(filterArray)); + toast.success("검색 필터 설정이 저장되었습니다."); + } + }, [user?.userId, component.componentId, searchFilterColumns]); + + // 🆕 검색 필터 토글 함수 + const handleToggleFilterColumn = useCallback((columnName: string) => { + setSearchFilterColumns((prev) => { + const newSet = new Set(prev); + if (newSet.has(columnName)) { + newSet.delete(columnName); + } else { + newSet.add(columnName); + } + return newSet; + }); + }, []); + // 데이터 삭제 핸들러 const handleDeleteData = useCallback(() => { if (selectedRows.size === 0) { @@ -1767,8 +1866,11 @@ export const InteractiveDataTable: React.FC = ({ case "number": case "decimal": - if (typeof value === "number") { - return value.toLocaleString(); + if (value !== null && value !== undefined && value !== "") { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue)) { + return numValue.toLocaleString("ko-KR"); + } } break; @@ -1909,27 +2011,97 @@ export const InteractiveDataTable: React.FC = ({ {visibleColumns.length > 0 ? ( <>
- - +
+ {/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */} {component.enableDelete && ( - + 0} onCheckedChange={handleSelectAll} /> )} - {visibleColumns.map((column: DataTableColumn) => ( - - {column.label} - - ))} + {visibleColumns.map((column: DataTableColumn, columnIndex) => { + const columnWidth = columnWidths[column.id]; + + return ( + (columnRefs.current[column.id] = el)} + className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors" + style={{ + width: columnWidth ? `${columnWidth}px` : undefined, + userSelect: 'none' + }} + > + {column.label} + {/* 리사이즈 핸들 */} + {columnIndex < visibleColumns.length - 1 && ( +
e.stopPropagation()} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + + const thElement = columnRefs.current[column.id]; + if (!thElement) return; + + isResizingRef.current = true; + + const startX = e.clientX; + const startWidth = columnWidth || thElement.offsetWidth; + + // 드래그 중 텍스트 선택 방지 + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const handleMouseMove = (moveEvent: MouseEvent) => { + moveEvent.preventDefault(); + + const diff = moveEvent.clientX - startX; + const newWidth = Math.max(80, startWidth + diff); + + // 직접 DOM 스타일 변경 (리렌더링 없음) + if (thElement) { + thElement.style.width = `${newWidth}px`; + } + }; + + const handleMouseUp = () => { + // 최종 너비를 state에 저장 + if (thElement) { + const finalWidth = Math.max(80, thElement.offsetWidth); + setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth })); + } + + // 텍스트 선택 복원 + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + + // 약간의 지연 후 리사이즈 플래그 해제 + setTimeout(() => { + isResizingRef.current = false; + }, 100); + + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }} + /> + )} + + ); + })} {/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */} @@ -1951,18 +2123,28 @@ export const InteractiveDataTable: React.FC = ({ {/* 체크박스 셀 (삭제 기능이 활성화된 경우) */} {component.enableDelete && ( - + handleRowSelect(rowIndex, checked as boolean)} /> )} - {visibleColumns.map((column: DataTableColumn) => ( - - {formatCellValue(row[column.columnName], column, row)} - - ))} + {visibleColumns.map((column: DataTableColumn) => { + const isNumeric = column.widgetType === "number" || column.widgetType === "decimal"; + return ( + + {formatCellValue(row[column.columnName], column, row)} + + ); + })} {/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */} )) diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 7ed39353..e85aab58 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -38,6 +38,7 @@ interface InteractiveScreenViewerProps { id: number; tableName?: string; }; + onSave?: () => Promise; } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -47,6 +48,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName, user } = useAuth(); @@ -204,8 +206,7 @@ export const InteractiveScreenViewerDynamic: React.FC { - // 화면 닫기 로직 (필요시 구현) - console.log("🚪 화면 닫기 요청"); + // buttonActions.ts가 이미 처리함 }} /> ); @@ -299,6 +300,18 @@ export const InteractiveScreenViewerDynamic: React.FC { + // EditModal에서 전달된 onSave가 있으면 우선 사용 (수정 모달) + if (onSave) { + try { + await onSave(); + } catch (error) { + console.error("저장 오류:", error); + toast.error("저장 중 오류가 발생했습니다."); + } + return; + } + + // 일반 저장 액션 (신규 생성) if (!screenInfo?.tableName) { toast.error("테이블명이 설정되지 않았습니다."); return; diff --git a/frontend/components/screen/MenuAssignmentModal.tsx b/frontend/components/screen/MenuAssignmentModal.tsx index 6e1c16cb..e04f3bda 100644 --- a/frontend/components/screen/MenuAssignmentModal.tsx +++ b/frontend/components/screen/MenuAssignmentModal.tsx @@ -46,6 +46,7 @@ export const MenuAssignmentModal: React.FC = ({ const [assignmentSuccess, setAssignmentSuccess] = useState(false); const [assignmentMessage, setAssignmentMessage] = useState(""); const searchInputRef = useRef(null); + const autoRedirectTimerRef = useRef(null); // 메뉴 목록 로드 (관리자 메뉴 + 사용자 메뉴) const loadMenus = async () => { @@ -98,7 +99,7 @@ export const MenuAssignmentModal: React.FC = ({ } }; - // 모달이 열릴 때 메뉴 목록 로드 + // 모달이 열릴 때 메뉴 목록 로드 및 정리 useEffect(() => { if (isOpen) { loadMenus(); @@ -107,7 +108,21 @@ export const MenuAssignmentModal: React.FC = ({ setSearchTerm(""); setAssignmentSuccess(false); setAssignmentMessage(""); + } else { + // 모달이 닫힐 때 타이머 정리 + if (autoRedirectTimerRef.current) { + clearTimeout(autoRedirectTimerRef.current); + autoRedirectTimerRef.current = null; + } } + + // 컴포넌트 언마운트 시 타이머 정리 + return () => { + if (autoRedirectTimerRef.current) { + clearTimeout(autoRedirectTimerRef.current); + autoRedirectTimerRef.current = null; + } + }; }, [isOpen]); // 메뉴 선택 처리 @@ -208,7 +223,7 @@ export const MenuAssignmentModal: React.FC = ({ } // 3초 후 자동으로 모달 닫고 화면 목록으로 이동 - setTimeout(() => { + autoRedirectTimerRef.current = setTimeout(() => { onClose(); // 모달 닫기 if (onBackToList) { onBackToList(); @@ -237,7 +252,7 @@ export const MenuAssignmentModal: React.FC = ({ } // 3초 후 자동으로 모달 닫고 화면 목록으로 이동 - setTimeout(() => { + autoRedirectTimerRef.current = setTimeout(() => { onClose(); // 모달 닫기 if (onBackToList) { onBackToList(); @@ -374,13 +389,20 @@ export const MenuAssignmentModal: React.FC = ({ +
+ )} + + {/* 🆕 선택된 컬럼의 검색 입력 필드 */} + {searchFilterColumns.size > 0 && ( +
+ {Array.from(searchFilterColumns).map((columnId) => { + const column = defaultColumns.find(col => col.id === columnId); + if (!column) return null; + + return ( +
+ + setSearchValues(prev => ({...prev, [columnId]: e.target.value}))} + disabled={isPreview} + className="h-9 text-sm" + /> +
+ ); + })} +
+ )} + {/* 검색 및 필터 영역 */}
{/* 검색 입력 */}
-
- - -
- {actions.showSearchButton && ( )}
- {/* 필터 영역 */} + {/* 기존 필터 영역 (이제는 사용하지 않음) */} {filters.length > 0 && (
@@ -352,6 +452,46 @@ export const DataTableTemplate: React.FC = ({
)} + + {/* 🆕 검색 필터 설정 다이얼로그 */} + + + + 검색 필터 설정 + + 표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다. + + + +
+ {defaultColumns.map((column) => ( +
+ handleToggleFilterColumn(column.id)} + /> + +
+ ))} +
+ + + + + +
+
); }; diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index bffae228..fd24ea19 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { FlowComponent } from "@/types/screen-management"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { AlertCircle, Loader2, ChevronUp, Filter, X } from "lucide-react"; +import { AlertCircle, Loader2, ChevronUp, Filter, X, Layers, ChevronDown, ChevronRight } from "lucide-react"; import { getFlowById, getAllStepCounts, @@ -40,6 +40,14 @@ import { Input } from "@/components/ui/input"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useAuth } from "@/hooks/useAuth"; +// 그룹화된 데이터 인터페이스 +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; +} + interface FlowWidgetProps { component: FlowComponent; onStepClick?: (stepId: number, stepName: string) => void; @@ -58,6 +66,28 @@ export function FlowWidget({ const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { user } = useAuth(); // 사용자 정보 가져오기 + // 숫자 포맷팅 함수 + const formatValue = (value: any): string => { + if (value === null || value === undefined || value === "") { + return "-"; + } + + // 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅 + if (typeof value === "number") { + return value.toLocaleString("ko-KR"); + } + + if (typeof value === "string") { + const numValue = parseFloat(value); + // 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅 + if (!isNaN(numValue) && numValue.toString() === value.trim()) { + return numValue.toLocaleString("ko-KR"); + } + } + + return String(value); + }; + // 🆕 전역 상태 관리 const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); const resetFlow = useFlowStepStore((state) => state.resetFlow); @@ -84,6 +114,11 @@ export function FlowWidget({ const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 + // 🆕 그룹 설정 관련 상태 + const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 + const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); // 접힌 그룹 + /** * 🆕 컬럼 표시 결정 함수 * 1순위: 플로우 스텝 기본 설정 (displayConfig) @@ -125,6 +160,12 @@ export function FlowWidget({ return `flowWidget_searchFilters_${user.userId}_${flowId}_${selectedStepId}`; }, [flowId, selectedStepId, user?.userId]); + // 🆕 그룹 설정 localStorage 키 생성 + const groupSettingKey = useMemo(() => { + if (!selectedStepId) return null; + return `flowWidget_groupSettings_step_${selectedStepId}`; + }, [selectedStepId]); + // 🆕 저장된 필터 설정 불러오기 useEffect(() => { if (!filterSettingKey || stepDataColumns.length === 0 || !user?.userId) return; @@ -141,43 +182,30 @@ export function FlowWidget({ // 초기값: 빈 필터 (사용자가 선택해야 함) setSearchFilterColumns(new Set()); } - - // 이전 사용자의 필터 설정 정리 (사용자 ID가 다른 키들 제거) - if (typeof window !== "undefined") { - const currentUserId = user.userId; - const keysToRemove: string[] = []; - - // localStorage의 모든 키를 확인 - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith("flowWidget_searchFilters_")) { - // 키 형식: flowWidget_searchFilters_${userId}_${flowId}_${stepId} - // split("_")를 하면 ["flowWidget", "searchFilters", "사용자ID", "플로우ID", "스텝ID"] - // 따라서 userId는 parts[2]입니다 - const parts = key.split("_"); - if (parts.length >= 3) { - const userIdFromKey = parts[2]; // flowWidget_searchFilters_ 다음이 userId - // 현재 사용자 ID와 다른 사용자의 설정은 제거 - if (userIdFromKey !== currentUserId) { - keysToRemove.push(key); - } - } - } - } - - // 이전 사용자의 설정 제거 - if (keysToRemove.length > 0) { - keysToRemove.forEach(key => { - localStorage.removeItem(key); - }); - } - } } catch (error) { console.error("필터 설정 불러오기 실패:", error); setSearchFilterColumns(new Set()); } }, [filterSettingKey, stepDataColumns, user?.userId]); + // 🆕 저장된 그룹 설정 불러오기 + useEffect(() => { + if (!groupSettingKey || stepDataColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + // 현재 단계에 표시되는 컬럼만 필터링 + const validGroups = savedGroups.filter((col: string) => stepDataColumns.includes(col)); + setGroupByColumns(validGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + setGroupByColumns([]); + } + }, [groupSettingKey, stepDataColumns]); + // 🆕 필터 설정 저장 const saveFilterSettings = useCallback(() => { if (!filterSettingKey) return; @@ -225,6 +253,92 @@ export function FlowWidget({ setFilteredData([]); }, []); + // 🆕 그룹 설정 저장 + const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } + }, [groupSettingKey, groupByColumns]); + + // 🆕 그룹 컬럼 토글 + const toggleGroupColumn = useCallback((columnName: string) => { + setGroupByColumns((prev) => { + if (prev.includes(columnName)) { + return prev.filter((col) => col !== columnName); + } else { + return [...prev, columnName]; + } + }); + }, []); + + // 🆕 그룹 펼치기/접기 토글 + const toggleGroupCollapse = useCallback((groupKey: string) => { + setCollapsedGroups((prev) => { + const newSet = new Set(prev); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + return newSet; + }); + }, []); + + // 🆕 그룹 해제 + const clearGrouping = useCallback(() => { + setGroupByColumns([]); + setCollapsedGroups(new Set()); + if (groupSettingKey) { + localStorage.removeItem(groupSettingKey); + } + toast.success("그룹이 해제되었습니다"); + }, [groupSettingKey]); + + // 🆕 데이터 그룹화 + const groupedData = useMemo((): GroupedData[] => { + const dataToGroup = filteredData.length > 0 ? filteredData : stepData; + + if (groupByColumns.length === 0 || dataToGroup.length === 0) return []; + + const grouped = new Map(); + + dataToGroup.forEach((item) => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupByColumns.map((col) => { + const value = item[col]; + const label = columnLabels[col] || col; + return `${label}:${value !== null && value !== undefined ? value : "-"}`; + }); + const groupKey = keyParts.join(" > "); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupByColumns.forEach((col) => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); + }, [filteredData, stepData, groupByColumns, columnLabels]); + // 🆕 검색 값이 변경될 때마다 자동 검색 (useEffect로 직접 처리) useEffect(() => { if (!stepData || stepData.length === 0) { @@ -657,17 +771,6 @@ export function FlowWidget({ return (
- {/* 플로우 제목 */} -
-
-

{flowData.name}

-
- - {flowData.description && ( -

{flowData.description}

- )} -
- {/* 플로우 스텝 목록 */}
{steps.map((step, index) => ( @@ -698,7 +801,7 @@ export function FlowWidget({ }`} > - {stepCounts[step.id] || 0} + {(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
@@ -754,85 +857,115 @@ export function FlowWidget({ {/* 선택된 스텝의 데이터 리스트 */} {selectedStepId !== null && (
- {/* 헤더 - 자동 높이 */} -
-
-
-

- {steps.find((s) => s.id === selectedStepId)?.stepName} -

-

- 총 {stepData.length}건의 데이터 - {filteredData.length > 0 && ( - (필터링: {filteredData.length}건) + {/* 필터 및 그룹 설정 */} + {stepDataColumns.length > 0 && ( + <> +

+
+ {/* 검색 필터 입력 영역 */} + {searchFilterColumns.size > 0 && ( + <> + {Array.from(searchFilterColumns).map((col) => ( + + setSearchValues((prev) => ({ + ...prev, + [col]: e.target.value, + })) + } + placeholder={`${columnLabels[col] || col} 검색...`} + className="h-8 text-xs w-40" + /> + ))} + {Object.keys(searchValues).length > 0 && ( + + )} + )} - {selectedRows.size > 0 && ( - ({selectedRows.size}건 선택됨) - )} -

+ + {/* 필터/그룹 설정 버튼 */} +
+ + +
+
- {/* 🆕 필터 설정 버튼 */} - {stepDataColumns.length > 0 && ( - - )} -
- - {/* 🆕 검색 필터 입력 영역 */} - {searchFilterColumns.size > 0 && ( -
-
- {Object.keys(searchValues).length > 0 && ( - - )} -
- -
- {Array.from(searchFilterColumns).map((col) => ( -
- - - setSearchValues((prev) => ({ - ...prev, - [col]: e.target.value, - })) - } - placeholder={`${columnLabels[col] || col} 검색...`} - className="h-8 text-xs" - /> -
- ))} + {/* 🆕 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+
)} -
+ + )} {/* 데이터 영역 - 고정 높이 + 스크롤 */} {stepDataLoading ? ( @@ -884,13 +1017,7 @@ export function FlowWidget({ {stepDataColumns.map((col) => (
{columnLabels[col] || col}: - - {row[col] !== null && row[col] !== undefined ? ( - String(row[col]) - ) : ( - - - )} - + {formatValue(row[col])}
))}
@@ -924,33 +1051,87 @@ export function FlowWidget({ - {paginatedStepData.map((row, pageIndex) => { - const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; - return ( - - {allowDataMove && ( - - toggleRowSelection(actualIndex)} - /> + {groupByColumns.length > 0 && groupedData.length > 0 ? ( + // 그룹화된 렌더링 + groupedData.flatMap((group) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + const groupRows = [ + + +
toggleGroupCollapse(group.groupKey)} + > + {isCollapsed ? ( + + ) : ( + + )} + {group.groupKey} + ({group.count}건) +
- )} - {stepDataColumns.map((col) => ( - - {row[col] !== null && row[col] !== undefined ? ( - String(row[col]) - ) : ( - - - )} - - ))} -
- ); - })} +
, + ]; + + if (!isCollapsed) { + const dataRows = group.items.map((row, itemIndex) => { + const actualIndex = displayData.indexOf(row); + return ( + + {allowDataMove && ( + + toggleRowSelection(actualIndex)} + /> + + )} + {stepDataColumns.map((col) => ( + + {formatValue(row[col])} + + ))} + + ); + }); + groupRows.push(...dataRows); + } + + return groupRows; + }) + ) : ( + // 일반 렌더링 (그룹 없음) + paginatedStepData.map((row, pageIndex) => { + const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; + return ( + + {allowDataMove && ( + + toggleRowSelection(actualIndex)} + /> + + )} + {stepDataColumns.map((col) => ( + + {formatValue(row[col])} + + ))} + + ); + }) + )}
@@ -964,7 +1145,7 @@ export function FlowWidget({ {/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
- 페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length}건) + 페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length.toLocaleString("ko-KR")}건)
표시 개수: @@ -1150,6 +1331,63 @@ export function FlowWidget({ + + {/* 🆕 그룹 설정 다이얼로그 */} + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {stepDataColumns.map((col) => ( +
+ toggleGroupColumn(col)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹:{" "} + + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
); } diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 695e5a51..3c885a8b 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -242,6 +242,12 @@ export const tableTypeApi = { return data.columns || data || []; }, + // 컬럼 입력 타입 정보 조회 + getColumnInputTypes: async (tableName: string): Promise => { + const response = await apiClient.get(`/table-management/tables/${tableName}/web-types`); + return response.data.data || []; + }, + // 컬럼 웹 타입 설정 setColumnWebType: async ( tableName: string, diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 9ee27c36..74a53561 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -19,9 +19,12 @@ import { TableIcon, Settings, X, + Layers, + ChevronDown, } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; +import { toast } from "sonner"; import { Dialog, DialogContent, @@ -35,6 +38,18 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; +// ======================================== +// 인터페이스 +// ======================================== + +// 그룹화된 데이터 인터페이스 +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; +} + // ======================================== // 캐시 및 유틸리티 // ======================================== @@ -244,12 +259,21 @@ export const TableListComponent: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [isDragging, setIsDragging] = useState(false); const [draggedRowIndex, setDraggedRowIndex] = useState(null); + const [columnWidths, setColumnWidths] = useState>({}); + const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); + const hasInitializedWidths = useRef(false); + const isResizing = useRef(false); // 필터 설정 관련 상태 const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); + // 그룹 설정 관련 상태 + const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); + const [groupByColumns, setGroupByColumns] = useState([]); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, preloadCommonCodes: true, @@ -284,20 +308,29 @@ export const TableListComponent: React.FC = ({ } const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); + + // 컬럼 입력 타입 정보 가져오기 + const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); + const inputTypeMap: Record = {}; + inputTypes.forEach((col: any) => { + inputTypeMap[col.columnName] = col.inputType; + }); tableColumnCache.set(cacheKey, { columns, + inputTypes, timestamp: Date.now(), }); const labels: Record = {}; - const meta: Record = {}; + const meta: Record = {}; columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; meta[col.columnName] = { webType: col.webType, codeCategory: col.codeCategory, + inputType: inputTypeMap[col.columnName], }; }); @@ -642,12 +675,46 @@ export const TableListComponent: React.FC = ({ } const meta = columnMeta[column.columnName]; - if (meta?.webType && meta?.codeCategory) { - const convertedValue = optimizedConvertCode(value, meta.codeCategory); - if (convertedValue !== value) return convertedValue; + + // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) + const inputType = meta?.inputType || column.inputType; + + // 코드 타입: 코드 값 → 코드명 변환 + if (inputType === "code" && meta?.codeCategory && value) { + try { + // optimizedConvertCode(categoryCode, codeValue) 순서 주의! + const convertedValue = optimizedConvertCode(meta.codeCategory, value); + // 변환에 성공했으면 변환된 코드명 반환 + if (convertedValue && convertedValue !== value) { + return convertedValue; + } + } catch (error) { + console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error); + } + // 변환 실패 시 원본 코드 값 반환 + return String(value); + } + + // 숫자 타입 포맷팅 + if (inputType === "number" || inputType === "decimal") { + if (value !== null && value !== undefined && value !== "") { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue)) { + return numValue.toLocaleString("ko-KR"); + } + } + return String(value); } switch (column.format) { + case "number": + if (value !== null && value !== undefined && value !== "") { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue)) { + return numValue.toLocaleString("ko-KR"); + } + } + return String(value); case "date": if (value) { try { @@ -681,9 +748,15 @@ export const TableListComponent: React.FC = ({ return `tableList_filterSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable]); + // 그룹 설정 localStorage 키 생성 + const groupSettingKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `tableList_groupSettings_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable]); + // 저장된 필터 설정 불러오기 useEffect(() => { - if (!filterSettingKey) return; + if (!filterSettingKey || visibleColumns.length === 0) return; try { const saved = localStorage.getItem(filterSettingKey); @@ -691,17 +764,14 @@ export const TableListComponent: React.FC = ({ const savedFilters = JSON.parse(saved); setVisibleFilterColumns(new Set(savedFilters)); } else { - // 초기값: 모든 필터 표시 - const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName); - setVisibleFilterColumns(new Set(allFilters)); + // 초기값: 빈 Set (아무것도 선택 안 함) + setVisibleFilterColumns(new Set()); } } catch (error) { console.error("필터 설정 불러오기 실패:", error); - // 기본값으로 모든 필터 표시 - const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName); - setVisibleFilterColumns(new Set(allFilters)); + setVisibleFilterColumns(new Set()); } - }, [filterSettingKey, tableConfig.filter?.filters]); + }, [filterSettingKey, visibleColumns]); // 필터 설정 저장 const saveFilterSettings = useCallback(() => { @@ -710,12 +780,17 @@ export const TableListComponent: React.FC = ({ try { localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); setIsFilterSettingOpen(false); + toast.success("검색 필터 설정이 저장되었습니다"); + + // 검색 값 초기화 + setSearchValues({}); } catch (error) { console.error("필터 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); } }, [filterSettingKey, visibleFilterColumns]); - // 필터 토글 + // 필터 컬럼 토글 const toggleFilterVisibility = useCallback((columnName: string) => { setVisibleFilterColumns((prev) => { const newSet = new Set(prev); @@ -728,10 +803,129 @@ export const TableListComponent: React.FC = ({ }); }, []); - // 표시할 필터 목록 + // 전체 선택/해제 + const toggleAllFilters = useCallback(() => { + const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + const columnNames = filterableColumns.map((col) => col.columnName); + + if (visibleFilterColumns.size === columnNames.length) { + // 전체 해제 + setVisibleFilterColumns(new Set()); + } else { + // 전체 선택 + setVisibleFilterColumns(new Set(columnNames)); + } + }, [visibleFilterColumns, visibleColumns]); + + // 표시할 필터 목록 (선택된 컬럼만) const activeFilters = useMemo(() => { - return (tableConfig.filter?.filters || []).filter((f) => visibleFilterColumns.has(f.columnName)); - }, [tableConfig.filter?.filters, visibleFilterColumns]); + return visibleColumns + .filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName)) + .map((col) => ({ + columnName: col.columnName, + label: columnLabels[col.columnName] || col.displayName || col.columnName, + type: col.format || "text", + })); + }, [visibleColumns, visibleFilterColumns, columnLabels]); + + // 그룹 설정 저장 + const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } + }, [groupSettingKey, groupByColumns]); + + // 그룹 컬럼 토글 + const toggleGroupColumn = useCallback((columnName: string) => { + setGroupByColumns((prev) => { + if (prev.includes(columnName)) { + return prev.filter((col) => col !== columnName); + } else { + return [...prev, columnName]; + } + }); + }, []); + + // 그룹 펼치기/접기 토글 + const toggleGroupCollapse = useCallback((groupKey: string) => { + setCollapsedGroups((prev) => { + const newSet = new Set(prev); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + return newSet; + }); + }, []); + + // 그룹 해제 + const clearGrouping = useCallback(() => { + setGroupByColumns([]); + setCollapsedGroups(new Set()); + if (groupSettingKey) { + localStorage.removeItem(groupSettingKey); + } + toast.success("그룹이 해제되었습니다"); + }, [groupSettingKey]); + + // 데이터 그룹화 + const groupedData = useMemo((): GroupedData[] => { + if (groupByColumns.length === 0 || data.length === 0) return []; + + const grouped = new Map(); + + data.forEach((item) => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupByColumns.map((col) => { + const value = item[col]; + const label = columnLabels[col] || col; + return `${label}:${value !== null && value !== undefined ? value : "-"}`; + }); + const groupKey = keyParts.join(" > "); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupByColumns.forEach((col) => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); + }, [data, groupByColumns, columnLabels]); + + // 저장된 그룹 설정 불러오기 + useEffect(() => { + if (!groupSettingKey || visibleColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + setGroupByColumns(savedGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + } + }, [groupSettingKey, visibleColumns]); useEffect(() => { fetchColumnLabels(); @@ -763,6 +957,38 @@ export const TableListComponent: React.FC = ({ } }, [tableConfig.refreshInterval, isDesignMode]); + // 초기 컬럼 너비 측정 (한 번만) + useEffect(() => { + if (!hasInitializedWidths.current && visibleColumns.length > 0) { + // 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정 + const timer = setTimeout(() => { + const newWidths: Record = {}; + let hasAnyWidth = false; + + visibleColumns.forEach((column) => { + // 체크박스 컬럼은 제외 (고정 48px) + if (column.columnName === "__checkbox__") return; + + const thElement = columnRefs.current[column.columnName]; + if (thElement) { + const measuredWidth = thElement.offsetWidth; + if (measuredWidth > 0) { + newWidths[column.columnName] = measuredWidth; + hasAnyWidth = true; + } + } + }); + + if (hasAnyWidth) { + setColumnWidths(newWidths); + hasInitializedWidths.current = true; + } + }, 100); + + return () => clearTimeout(timer); + } + }, [visibleColumns]); + // ======================================== // 페이지네이션 JSX // ======================================== @@ -872,14 +1098,6 @@ export const TableListComponent: React.FC = ({ if (tableConfig.stickyHeader && !isDesignMode) { return (
- {tableConfig.showHeader && ( -
-

- {tableConfig.title || tableLabel || finalSelectedTable} -

-
- )} - {tableConfig.filter?.enabled && (
@@ -892,15 +1110,52 @@ export const TableListComponent: React.FC = ({ onClear={handleClearAdvancedFilters} />
- + +
+
+
+ )} + + {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+ + +
)} @@ -935,15 +1190,6 @@ export const TableListComponent: React.FC = ({ return ( <>
- {/* 헤더 */} - {tableConfig.showHeader && ( -
-

- {tableConfig.title || tableLabel || finalSelectedTable} -

-
- )} - {/* 필터 */} {tableConfig.filter?.enabled && (
@@ -957,15 +1203,52 @@ export const TableListComponent: React.FC = ({ onClear={handleClearAdvancedFilters} />
- + +
+ + + )} + + {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+ + +
)} @@ -982,38 +1265,110 @@ export const TableListComponent: React.FC = ({ style={{ borderCollapse: "collapse", width: "100%", + tableLayout: "fixed", }} > {/* 헤더 (sticky) */} - - {visibleColumns.map((column) => ( - column.sortable && handleSort(column.columnName)} - > - {column.columnName === "__checkbox__" ? ( - renderCheckboxHeader() - ) : ( -
- {columnLabels[column.columnName] || column.displayName} - {column.sortable && sortColumn === column.columnName && ( - {sortDirection === "asc" ? "↑" : "↓"} - )} -
- )} - - ))} + + {visibleColumns.map((column, columnIndex) => { + const columnWidth = columnWidths[column.columnName]; + + return ( + (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" + )} + 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' + }} + onClick={() => { + if (isResizing.current) return; + if (column.sortable) handleSort(column.columnName); + }} + > + {column.columnName === "__checkbox__" ? ( + renderCheckboxHeader() + ) : ( +
+ {columnLabels[column.columnName] || column.displayName} + {column.sortable && sortColumn === column.columnName && ( + {sortDirection === "asc" ? "↑" : "↓"} + )} +
+ )} + {/* 리사이즈 핸들 (체크박스 제외) */} + {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && ( +
e.stopPropagation()} // 정렬 클릭 방지 + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + + const thElement = columnRefs.current[column.columnName]; + if (!thElement) return; + + isResizing.current = true; + + const startX = e.clientX; + const startWidth = columnWidth || thElement.offsetWidth; + + // 드래그 중 텍스트 선택 방지 + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const handleMouseMove = (moveEvent: MouseEvent) => { + moveEvent.preventDefault(); + + const diff = moveEvent.clientX - startX; + const newWidth = Math.max(80, startWidth + diff); + + // 직접 DOM 스타일 변경 (리렌더링 없음) + if (thElement) { + thElement.style.width = `${newWidth}px`; + } + }; + + const handleMouseUp = () => { + // 최종 너비를 state에 저장 + if (thElement) { + const finalWidth = Math.max(80, thElement.offsetWidth); + setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth })); + } + + // 텍스트 선택 복원 + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + + // 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록) + setTimeout(() => { + isResizing.current = false; + }, 100); + + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }} + /> + )} + + ); + })} @@ -1049,7 +1404,81 @@ export const TableListComponent: React.FC = ({
+ ) : groupByColumns.length > 0 && groupedData.length > 0 ? ( + // 그룹화된 렌더링 + groupedData.map((group) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + return ( + + {/* 그룹 헤더 */} + + +
toggleGroupCollapse(group.groupKey)} + > + {isCollapsed ? ( + + ) : ( + + )} + {group.groupKey} + ({group.count}건) +
+ + + {/* 그룹 데이터 */} + {!isCollapsed && + group.items.map((row, index) => ( + 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" + )} + onClick={() => handleRowClick(row)} + > + {visibleColumns.map((column) => { + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const cellValue = row[mappedColumnName]; + + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ( + + {column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(cellValue, column, row)} + + ); + })} + + ))} +
+ ); + }) ) : ( + // 일반 렌더링 (그룹 없음) data.map((row, index) => ( = ({ const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const cellValue = row[mappedColumnName]; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + return ( {column.columnName === "__checkbox__" @@ -1100,26 +1536,63 @@ export const TableListComponent: React.FC = ({ 검색 필터 설정 - 표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다. + 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다. -
- {(tableConfig.filter?.filters || []).map((filter) => ( -
- toggleFilterVisibility(filter.columnName)} - /> - -
- ))} +
+ {/* 전체 선택/해제 */} +
+ col.columnName !== "__checkbox__").length && + visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0 + } + onCheckedChange={toggleAllFilters} + /> + + + {visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length} + 개 + +
+ + {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleFilterVisibility(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 컬럼 개수 안내 */} +
+ {visibleFilterColumns.size === 0 ? ( + 검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요 + ) : ( + + 총 {visibleFilterColumns.size}개의 검색 필터가 + 표시됩니다 + + )} +
@@ -1136,6 +1609,68 @@ export const TableListComponent: React.FC = ({ + + {/* 그룹 설정 다이얼로그 */} + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹:{" "} + + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
); }; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 4c376b09..8b805d93 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -234,9 +234,13 @@ export class ButtonActionExecutor { throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); } - // 테이블과 플로우 모두 새로고침 + // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) context.onRefresh?.(); context.onFlowRefresh?.(); + + // 저장 성공 후 EditModal 닫기 이벤트 발생 + window.dispatchEvent(new CustomEvent("closeEditModal")); + return true; } catch (error) { console.error("저장 오류:", error); diff --git a/테이블_그룹핑_기능_구현_계획서.md b/테이블_그룹핑_기능_구현_계획서.md new file mode 100644 index 00000000..b6b86afa --- /dev/null +++ b/테이블_그룹핑_기능_구현_계획서.md @@ -0,0 +1,365 @@ +# 테이블 그룹핑 기능 구현 계획서 + +## 📋 개요 + +테이블 리스트 컴포넌트와 플로우 위젯에 그룹핑 기능을 추가하여, 사용자가 선택한 컬럼(들)을 기준으로 데이터를 그룹화하여 표시합니다. + +## 🎯 핵심 요구사항 + +### 1. 기능 요구사항 +- ✅ 그룹핑할 컬럼을 다중 선택 가능 +- ✅ 선택한 컬럼 순서대로 계층적 그룹화 +- ✅ 그룹 헤더에 그룹 정보와 데이터 개수 표시 +- ✅ 그룹 펼치기/접기 기능 +- ✅ localStorage에 그룹 설정 저장/복원 +- ✅ 그룹 해제 기능 + +### 2. 적용 대상 +- TableListComponent (`frontend/lib/registry/components/table-list/TableListComponent.tsx`) +- FlowWidget (`frontend/components/screen/widgets/FlowWidget.tsx`) + +## 🎨 UI 디자인 + +### 그룹 설정 다이얼로그 + +```tsx +┌─────────────────────────────────────┐ +│ 📊 그룹 설정 │ +│ 데이터를 그룹화할 컬럼을 선택하세요 │ +├─────────────────────────────────────┤ +│ │ +│ [x] 통화 │ +│ [ ] 단위 │ +│ [ ] 품목코드 │ +│ [ ] 품목명 │ +│ [ ] 규격 │ +│ │ +│ 💡 선택된 그룹: 통화 │ +│ │ +├─────────────────────────────────────┤ +│ [취소] [적용] │ +└─────────────────────────────────────┘ +``` + +### 그룹화된 테이블 표시 + +```tsx +┌─────────────────────────────────────────────────────┐ +│ 📦 판매품목 목록 총 3개 [🎨 그룹: 통화 ×] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ ▼ 통화: KRW > 단위: EA (2건) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │ +│ ├─────────────────────────────────────────────┤ │ +│ │ SALE-001 │ 볼트 M8x20 │ M8x20 │ EA │ │ +│ │ SALE-004 │ 스프링 와셔 │ M10 │ EA │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ▼ 통화: USD > 단위: EA (1건) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │ +│ ├─────────────────────────────────────────────┤ │ +│ │ SALE-002 │ 너트 M8 │ M8 │ EA │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +## 🔧 기술 구현 + +### 1. 상태 관리 + +```typescript +// 그룹 설정 관련 상태 +const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 +const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 +const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); // 접힌 그룹 +``` + +### 2. 데이터 그룹화 로직 + +```typescript +interface GroupedData { + groupKey: string; // "통화:KRW > 단위:EA" + groupValues: Record; // { 통화: "KRW", 단위: "EA" } + items: any[]; // 그룹에 속한 데이터 + count: number; // 항목 개수 +} + +const groupDataByColumns = ( + data: any[], + groupColumns: string[] +): GroupedData[] => { + if (groupColumns.length === 0) return []; + + const grouped = new Map(); + + data.forEach(item => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupColumns.map(col => `${col}:${item[col] || '-'}`); + const groupKey = keyParts.join(' > '); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupColumns.forEach(col => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); +}; +``` + +### 3. localStorage 저장/로드 + +```typescript +// 저장 키 +const groupSettingKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `table-list-group-${tableConfig.selectedTable}`; +}, [tableConfig.selectedTable]); + +// 그룹 설정 저장 +const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } +}, [groupSettingKey, groupByColumns]); + +// 그룹 설정 로드 +useEffect(() => { + if (!groupSettingKey || visibleColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + setGroupByColumns(savedGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + } +}, [groupSettingKey, visibleColumns]); +``` + +### 4. 그룹 헤더 렌더링 + +```tsx +const renderGroupHeader = (group: GroupedData) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + + return ( +
toggleGroupCollapse(group.groupKey)} + > + {/* 펼치기/접기 아이콘 */} + {isCollapsed ? ( + + ) : ( + + )} + + {/* 그룹 정보 */} + + {groupByColumns.map((col, idx) => ( + + {idx > 0 && > } + {columnLabels[col] || col}: + {" "} + {group.groupValues[col]} + + ))} + + + {/* 항목 개수 */} + + ({group.count}건) + +
+ ); +}; +``` + +### 5. 그룹 설정 다이얼로그 + +```tsx + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹: + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
+``` + +### 6. 그룹 해제 버튼 + +```tsx +{/* 헤더 영역 */} +
+

{tableLabel}

+
+ {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+ 그룹: {groupByColumns.map(col => columnLabels[col] || col).join(", ")} + +
+ )} + + {/* 그룹 설정 버튼 */} + +
+
+``` + +## 📝 구현 순서 + +### Phase 1: TableListComponent 구현 +1. ✅ 상태 관리 추가 (groupByColumns, isGroupSettingOpen, collapsedGroups) +2. ✅ 그룹화 로직 구현 (groupDataByColumns 함수) +3. ✅ localStorage 저장/로드 로직 +4. ✅ 그룹 설정 다이얼로그 UI +5. ✅ 그룹 헤더 렌더링 +6. ✅ 그룹별 데이터 렌더링 +7. ✅ 그룹 해제 기능 + +### Phase 2: FlowWidget 구현 +1. ✅ TableListComponent와 동일한 로직 적용 +2. ✅ 스텝 데이터에 그룹화 적용 +3. ✅ UI 통일성 유지 + +### Phase 3: 테스트 및 최적화 +1. ✅ 다중 그룹 계층 테스트 +2. ✅ 대량 데이터 성능 테스트 +3. ✅ localStorage 저장/복원 테스트 +4. ✅ 그룹 펼치기/접기 테스트 + +## 🎯 예상 효과 + +### 사용자 경험 개선 +- 데이터를 논리적으로 그룹화하여 가독성 향상 +- 대량 데이터를 효율적으로 탐색 가능 +- 사용자 정의 뷰 제공 + +### 데이터 분석 지원 +- 카테고리별 데이터 분석 용이 +- 통계 정보 제공 (그룹별 개수) +- 계층적 데이터 구조 시각화 + +## ⚠️ 주의사항 + +### 성능 고려사항 +- 그룹화는 클라이언트 측에서 수행 +- 대량 데이터의 경우 성능 저하 가능 +- 필요시 서버 측 그룹화로 전환 검토 + +### 사용성 +- 그룹화 해제가 쉽게 가능해야 함 +- 그룹 설정이 직관적이어야 함 +- 모바일에서도 사용 가능한 UI + +## 📊 구현 상태 + +- [ ] Phase 1: TableListComponent 구현 + - [ ] 상태 관리 추가 + - [ ] 그룹화 로직 구현 + - [ ] localStorage 연동 + - [ ] UI 구현 +- [ ] Phase 2: FlowWidget 구현 +- [ ] Phase 3: 테스트 및 최적화 + +--- + +**작성일**: 2025-11-03 +**버전**: 1.0 +**상태**: 구현 예정 + diff --git a/화면관리_및_테이블관리_개선사항_목록.md b/화면관리_및_테이블관리_개선사항_목록.md new file mode 100644 index 00000000..666f41f1 --- /dev/null +++ b/화면관리_및_테이블관리_개선사항_목록.md @@ -0,0 +1,386 @@ +# 화면관리 및 테이블관리 시스템 개선사항 목록 + +## 문서 정보 +- **작성일**: 2025-11-03 +- **목적**: 사용자 피드백 기반 개선사항 정리 +- **우선순위**: 높음 + +--- + +## 1. 화면관리 (Screen Management) 개선사항 + +### 1.1 리스트 컬럼 Width 조절 기능 +**현재 문제**: 리스트 컬럼의 너비가 고정되어 있어 사용자가 조절할 수 없음 + +**요구사항**: +- 사용자가 각 컬럼의 너비를 드래그로 조절할 수 있어야 함 +- 조절된 너비는 저장되어 다음 접속 시에도 유지되어야 함 +- 최소/최대 너비 제한 필요 + +**구현 방안**: +- 컬럼 헤더에 리사이저 핸들 추가 +- `ComponentData` 인터페이스에 `columnWidths` 속성 추가 +- PropertiesPanel에서 개별 컬럼 너비 설정 UI 제공 + +**관련 파일**: +- `frontend/components/screen/ScreenDesigner.tsx` +- `frontend/components/screen/RealtimePreview.tsx` +- `frontend/types/screen.ts` + +--- + +### 1.2 되돌리기(Undo) 단축키 에러 수정 +**현재 문제**: 되돌리기 단축키(Ctrl+Z/Cmd+Z) 실행 시 에러 발생 + +**요구사항**: +- 되돌리기 기능이 안정적으로 작동해야 함 +- 다시 실행(Redo) 기능도 함께 제공 (Ctrl+Y/Cmd+Shift+Z) + +**구현 방안**: +- 히스토리 스택 구현 (최대 50개 상태 저장) +- `useUndo` 커스텀 훅 생성 +- 키보드 단축키 이벤트 리스너 추가 + +**관련 파일**: +- `frontend/hooks/useUndo.ts` (신규 생성) +- `frontend/components/screen/ScreenDesigner.tsx` + +--- + +### 1.3 리스트 헤더 스타일 개선 +**현재 문제**: 리스트 헤더가 눈에 잘 띄지 않음 + +**요구사항**: +- 헤더가 시각적으로 구분되어야 함 +- 배경색, 폰트 굵기, 테두리 등으로 강조 + +**구현 방안**: +- 헤더 기본 스타일 변경: + - 배경색: `bg-muted` → `bg-primary/10` + - 폰트: `font-medium` → `font-semibold` + - 하단 테두리: `border-b-2 border-primary` + +**관련 파일**: +- `frontend/components/screen/RealtimePreview.tsx` +- `frontend/components/screen-viewer/InteractiveScreenViewer.tsx` + +--- + +### 1.4 텍스트 줄바꿈 문제 방지 +**현재 문제**: 화면을 줄였을 때 텍스트가 2줄로 나뉘거나 깨지는 현상 + +**요구사항**: +- 텍스트가 항상 1줄로 표시되어야 함 +- 긴 텍스트는 말줄임표(...) 처리 + +**구현 방안**: +- 모든 텍스트 요소에 다음 클래스 적용: + ```tsx + className="truncate whitespace-nowrap overflow-hidden" + ``` +- 툴팁으로 전체 텍스트 표시 + +**관련 파일**: +- 모든 컴포넌트의 텍스트 렌더링 부분 + +--- + +### 1.5 수정 모달 자동 닫기 +**현재 문제**: 수정 완료 후 모달이 자동으로 닫히지 않음 + +**요구사항**: +- 수정 완료 시 모달이 즉시 닫혀야 함 +- 성공 메시지 표시 후 닫기 + +**구현 방안**: +```typescript +const handleUpdate = async () => { + const result = await updateData(formData); + if (result.success) { + toast.success("수정이 완료되었습니다"); + setIsModalOpen(false); // 모달 닫기 + refreshList(); // 목록 새로고침 + } +}; +``` + +**관련 파일**: +- `frontend/components/screen-viewer/InteractiveScreenViewer.tsx` + +--- + +### 1.6 테이블 Align 조절 기능 +**현재 문제**: 테이블 컬럼의 정렬(align)을 사용자가 조절할 수 없음 + +**요구사항**: +- 각 컬럼의 정렬을 left/center/right로 설정 가능해야 함 +- 숫자 타입은 기본적으로 right 정렬 + +**구현 방안**: +- `TableColumnConfig` 인터페이스에 `align` 속성 추가 +- PropertiesPanel에서 정렬 선택 UI 제공 +- 컬럼 타입별 기본 정렬 설정 + +**관련 파일**: +- `frontend/types/screen.ts` +- `frontend/components/screen/PropertiesPanel.tsx` + +--- + +### 1.7 숫자 천 단위 콤마 표시 +**현재 문제**: 숫자가 콤마 없이 표시됨 + +**요구사항**: +- 모든 숫자는 천 단위마다 콤마(,)를 찍어야 함 +- 예: 1000000 → 1,000,000 + +**구현 방안**: +```typescript +// 유틸리티 함수 생성 +export const formatNumber = (value: number | string): string => { + const num = typeof value === "string" ? parseFloat(value) : value; + if (isNaN(num)) return "0"; + return new Intl.NumberFormat("ko-KR").format(num); +}; +``` + +**관련 파일**: +- `frontend/lib/utils/numberFormat.ts` (신규 생성) +- 모든 숫자 표시 컴포넌트 + +--- + +### 1.8 Drilldown UI 개선 +**현재 문제**: 화면이 횡으로 너무 길게 나열됨 + +**요구사항**: +- 계층적 구조로 정보 표시 +- 펼치기/접기 기능으로 공간 절약 + +**구현 방안**: +- Accordion 컴포넌트 활용 +- 탭 네비게이션 구조 적용 +- 마스터-디테일 레이아웃 패턴 + +**관련 파일**: +- `frontend/components/screen/ScreenDesigner.tsx` +- `frontend/components/ui/accordion.tsx` + +--- + +## 2. 테이블 관리 (Table Management) 개선사항 + +### 2.1 테이블 기본 정보 선택 기능 +**현재 문제**: 테이블 기본 정보를 사용자가 선택할 수 없음 + +**요구사항**: +- 테이블 생성/수정 시 다음 정보를 선택 가능해야 함: + - 테이블 타입 (마스터/트랜잭션/코드) + - 카테고리 + - 로그 사용 여부 + - 버전 관리 여부 + - 소프트 삭제 여부 + +**구현 방안**: +- `TableManagement.tsx`에 선택 UI 추가 +- `CREATE TABLE` DDL 자동 생성 시 옵션 반영 + +**관련 파일**: +- `frontend/components/table/TableManagement.tsx` +- `backend-node/src/controllers/tableController.ts` + +--- + +### 2.2 컬럼 추가 기능 +**현재 문제**: 기존 테이블에 새 컬럼을 추가하는 기능 부족 + +**요구사항**: +- 테이블 수정 시 컬럼을 동적으로 추가할 수 있어야 함 +- `ALTER TABLE ADD COLUMN` DDL 자동 생성 +- 컬럼 순서 조정 기능 + +**구현 방안**: +```typescript +// 컬럼 추가 API +POST /api/table-management/tables/:tableName/columns +{ + "columnName": "new_column", + "dataType": "VARCHAR(100)", + "nullable": true, + "defaultValue": null +} +``` + +**관련 파일**: +- `frontend/components/table/TableManagement.tsx` +- `backend-node/src/controllers/tableController.ts` +- `backend-node/src/services/ddlExecutionService.ts` + +--- + +### 2.3 테이블 복제 기능 +**현재 문제**: 기존 테이블의 구조를 재사용하기 어려움 + +**요구사항**: +- 기존 테이블을 복제하여 새 테이블 생성 +- 다음 정보를 복사: + - 컬럼 구조 (이름, 타입, 제약조건) + - 인덱스 정의 + - 외래키 관계 (선택적) +- 데이터는 복사하지 않음 (구조만) + +**구현 방안**: +```typescript +// 테이블 복제 API +POST /api/table-management/tables/:sourceTableName/clone +{ + "newTableName": "cloned_table", + "includeIndexes": true, + "includeForeignKeys": false, + "copyData": false +} +``` + +**구현 단계**: +1. 원본 테이블 정보 조회 (INFORMATION_SCHEMA) +2. DDL 스크립트 생성 +3. 새 테이블 생성 +4. 인덱스 및 제약조건 추가 +5. 감사 로그 기록 + +**관련 파일**: +- `frontend/components/table/TableManagement.tsx` +- `backend-node/src/controllers/tableController.ts` +- `backend-node/src/services/ddlExecutionService.ts` + +**참고 문서**: +- `/Users/kimjuseok/ERP-node/테이블_복제_기능_구현_계획서.md` + +--- + +### 2.4 채번 Rule 관리 기능 +**현재 문제**: 자동 채번 규칙을 사용자가 관리할 수 없음 + +**요구사항**: +- 채번 규칙 생성/수정/삭제 UI +- 규칙 형식: + - 접두사 (예: "PROD-") + - 날짜 포맷 (예: "YYYYMMDD") + - 일련번호 자릿수 (예: 5자리 → 00001) + - 구분자 (예: "-") +- 예시: `PROD-20251103-00001` + +**구현 방안**: +```typescript +interface NumberingRule { + id: string; + ruleName: string; + prefix: string; + dateFormat?: "YYYY" | "YYYYMM" | "YYYYMMDD" | "YYYYMMDD-HH"; + sequenceDigits: number; + separator: string; + resetPeriod: "none" | "daily" | "monthly" | "yearly"; + currentSequence: number; + tableName: string; + columnName: string; +} +``` + +**관련 파일**: +- `frontend/components/table/NumberingRuleManagement.tsx` (신규 생성) +- `backend-node/src/controllers/numberingRuleController.ts` (신규 생성) +- `backend-node/src/services/numberingRuleService.ts` (신규 생성) + +--- + +## 3. 제어 관리 (Flow Management) 개선사항 + +### 3.1 제목 클릭 시 노드 선택 해제 +**현재 문제**: 제목을 입력할 때 백스페이스를 누르면 노드가 삭제됨 + +**요구사항**: +- 제목(플로우명) 입력란 클릭 시 노드 선택이 해제되어야 함 +- 백스페이스 키가 텍스트 입력으로만 작동해야 함 + +**구현 방안**: +```typescript +const handleTitleClick = (e: React.MouseEvent) => { + e.stopPropagation(); // 이벤트 전파 중지 + setSelectedNodes([]); // 노드 선택 해제 +}; + +const handleTitleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); // 백스페이스 키가 노드 삭제로 전파되지 않도록 +}; + + setFlowName(e.target.value)} +/> +``` + +**관련 파일**: +- `frontend/components/flow/FlowDesigner.tsx` +- `frontend/components/flow/FlowCanvas.tsx` + +--- + +## 4. 우선순위 및 구현 일정 + +### 높음 (즉시 수정 필요) +1. **되돌리기 단축키 에러 수정** - 기능 오류 +2. **수정 모달 자동 닫기** - 사용자 경험 저해 +3. **제어관리 제목 입력 문제** - 데이터 손실 위험 +4. **숫자 천 단위 콤마 표시** - 가독성 문제 + +### 중간 (2주 내 완료) +5. **리스트 컬럼 Width 조절** +6. **리스트 헤더 스타일 개선** +7. **텍스트 줄바꿈 문제 방지** +8. **테이블 Align 조절** +9. **컬럼 추가 기능** + +### 낮음 (기능 추가) +10. **테이블 기본 정보 선택** +11. **테이블 복제 기능** +12. **Drilldown UI 개선** +13. **채번 Rule 관리** + +--- + +## 5. 테스트 계획 + +각 개선사항 완료 시 다음을 확인: + +### 기능 테스트 +- [ ] 새 기능이 정상 작동함 +- [ ] 기존 기능에 영향 없음 +- [ ] 에러 처리가 적절함 + +### 사용자 경험 테스트 +- [ ] UI가 직관적임 +- [ ] 반응 속도가 빠름 +- [ ] 모바일/태블릿 대응 + +### 성능 테스트 +- [ ] 대량 데이터 처리 시 성능 저하 없음 +- [ ] 메모리 누수 없음 + +--- + +## 6. 참고 문서 + +- [화면관리 시스템 현황](화면관리_및_테이블관리_개선사항_목록.md) +- [테이블 복제 기능 계획서](테이블_복제_기능_구현_계획서.md) +- [Shadcn/ui 레이아웃 패턴](docs/shadcn-ui-레이아웃-패턴-분석-보고서.md) + +--- + +## 변경 이력 + +| 날짜 | 작성자 | 내용 | +|------|--------|------| +| 2025-11-03 | 개발팀 | 초안 작성 | +