From 8c19d57ced949bde4c9ffcce6544791d3e4b051f Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 30 Sep 2025 10:30:05 +0900 Subject: [PATCH] =?UTF-8?q?ui,=20=EC=99=B8=EB=B6=80=EC=BB=A4=EB=84=A5?= =?UTF-8?q?=EC=85=98=EC=97=90=EC=84=9C=20=EC=BF=BC=EB=A6=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A7=8C=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflowExecutionController.ts | 27 +-- .../src/services/batchExternalDbService.ts | 7 +- .../src/services/dataflowControlService.ts | 15 +- .../services/externalDbConnectionService.ts | 24 +++ .../services/multiConnectionQueryService.ts | 27 ++- frontend/app/globals.css | 9 + frontend/app/layout.tsx | 2 + frontend/components/admin/SqlQueryModal.tsx | 26 +++ frontend/components/admin/UserToolbar.tsx | 4 +- frontend/components/screen/EditModal.tsx | 6 +- frontend/components/screen/FloatingPanel.tsx | 4 +- .../screen/InteractiveScreenViewerDynamic.tsx | 9 +- .../screen/RealtimePreviewDynamic.tsx | 16 +- frontend/components/screen/ScreenDesigner.tsx | 8 +- .../screen/filters/AdvancedSearchFilters.tsx | 12 +- .../components/screen/widgets/FileUpload.tsx | 178 +++++++++++++----- .../screen/widgets/types/FileWidget.tsx | 112 +++++++---- frontend/components/ui/alert-dialog.tsx | 4 +- frontend/components/ui/dialog.tsx | 4 +- frontend/components/ui/popover.tsx | 2 +- frontend/components/ui/select.tsx | 2 +- .../card-display/CardDisplayComponent.tsx | 37 +++- .../table-list/SingleTableWithSticky.tsx | 50 ++--- .../table-list/TableListComponent.tsx | 92 ++++----- 24 files changed, 452 insertions(+), 225 deletions(-) diff --git a/backend-node/src/controllers/dataflowExecutionController.ts b/backend-node/src/controllers/dataflowExecutionController.ts index 766ba90c..7f8dc0f1 100644 --- a/backend-node/src/controllers/dataflowExecutionController.ts +++ b/backend-node/src/controllers/dataflowExecutionController.ts @@ -91,7 +91,7 @@ async function executeMainDatabaseAction( } /** - * 외부 데이터베이스에서 데이터 액션 실행 + * 외부 데이터베이스에서 데이터 액션 실행 (보안상 비활성화) */ async function executeExternalDatabaseAction( tableName: string, @@ -99,29 +99,8 @@ async function executeExternalDatabaseAction( actionType: string, connection: any ): Promise { - try { - logger.info(`외부 DB 액션 실행: ${connection.name} (${connection.host}:${connection.port})`); - logger.info(`테이블: ${tableName}, 액션: ${actionType}`, data); - - // 🔥 실제 외부 DB 연결 및 실행 로직 구현 - const { MultiConnectionQueryService } = await import('../services/multiConnectionQueryService'); - const queryService = new MultiConnectionQueryService(); - - let result; - switch (actionType.toLowerCase()) { - case 'insert': - result = await queryService.insertDataToConnection(connection.id, tableName, data); - logger.info(`외부 DB INSERT 성공:`, result); - break; - case 'update': - // TODO: UPDATE 로직 구현 (조건 필요) - throw new Error('UPDATE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다.'); - case 'delete': - // TODO: DELETE 로직 구현 (조건 필요) - throw new Error('DELETE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다.'); - default: - throw new Error(`지원하지 않는 액션 타입: ${actionType}`); - } + // 보안상 외부 DB에 대한 모든 데이터 변경 작업은 비활성화 + throw new Error(`보안상 외부 데이터베이스에 대한 ${actionType.toUpperCase()} 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.`); return { success: true, diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index d5670f04..6cbc8ca5 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -551,13 +551,18 @@ export class BatchExternalDbService { } /** - * 외부 DB 테이블에 데이터 삽입 + * 외부 DB 테이블에 데이터 삽입 (보안상 비활성화) */ static async insertDataToTable( connectionId: number, tableName: string, data: any[] ): Promise> { + // 보안상 외부 DB에 대한 INSERT 작업은 비활성화 + return { + success: false, + message: "보안상 외부 데이터베이스에 대한 INSERT 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.", + }; try { console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드`); diff --git a/backend-node/src/services/dataflowControlService.ts b/backend-node/src/services/dataflowControlService.ts index daefcadd..07a6bd79 100644 --- a/backend-node/src/services/dataflowControlService.ts +++ b/backend-node/src/services/dataflowControlService.ts @@ -937,23 +937,14 @@ export class DataflowControlService { } /** - * DELETE 액션 실행 - 조건 기반으로만 삭제 + * DELETE 액션 실행 - 보안상 외부 DB 비활성화 */ private async executeDeleteAction( action: ControlAction, sourceData: Record ): Promise { - console.log(`🗑️ DELETE 액션 실행 시작:`, { - actionName: action.name, - conditions: action.conditions, - }); - - // DELETE는 조건이 필수 - if (!action.conditions || action.conditions.length === 0) { - throw new Error( - "DELETE 액션에는 반드시 조건이 필요합니다. 전체 테이블 삭제는 위험합니다." - ); - } + // 보안상 외부 DB에 대한 DELETE 작업은 비활성화 + throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요."); const results = []; diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index 0d5fa1bc..17be5114 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -699,6 +699,30 @@ export class ExternalDbConnectionService { params: any[] = [] ): Promise> { try { + // 보안 검증: SELECT 쿼리만 허용 + const trimmedQuery = query.trim().toUpperCase(); + if (!trimmedQuery.startsWith('SELECT')) { + console.log("보안 오류: SELECT가 아닌 쿼리 시도:", { id, query: query.substring(0, 100) }); + return { + success: false, + message: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다.", + }; + } + + // 위험한 키워드 검사 + const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE', 'CALL', 'MERGE']; + const hasDangerousKeyword = dangerousKeywords.some(keyword => + trimmedQuery.includes(keyword) + ); + + if (hasDangerousKeyword) { + console.log("보안 오류: 위험한 키워드 포함 쿼리 시도:", { id, query: query.substring(0, 100) }); + return { + success: false, + message: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.", + }; + } + // 연결 정보 조회 console.log("연결 정보 조회 시작:", { id }); const connection = await prisma.external_db_connections.findUnique({ diff --git a/backend-node/src/services/multiConnectionQueryService.ts b/backend-node/src/services/multiConnectionQueryService.ts index 5ce9ca68..0f05269e 100644 --- a/backend-node/src/services/multiConnectionQueryService.ts +++ b/backend-node/src/services/multiConnectionQueryService.ts @@ -119,22 +119,25 @@ export class MultiConnectionQueryService { } /** - * 대상 커넥션에 데이터 삽입 + * 대상 커넥션에 데이터 삽입 (보안상 외부 DB 비활성화) */ async insertDataToConnection( connectionId: number, tableName: string, data: Record ): Promise { + // 보안상 외부 DB에 대한 INSERT 작업은 비활성화 + if (connectionId !== 0) { + throw new Error("보안상 외부 데이터베이스에 대한 INSERT 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요."); + } + try { logger.info( `데이터 삽입 시작: connectionId=${connectionId}, table=${tableName}` ); - // connectionId가 0이면 메인 DB 사용 - if (connectionId === 0) { - return await this.executeOnMainDatabase("insert", tableName, data); - } + // connectionId가 0이면 메인 DB 사용 (내부 DB만 허용) + return await this.executeOnMainDatabase("insert", tableName, data); // 외부 DB 연결 정보 가져오기 const connectionResult = @@ -288,7 +291,7 @@ export class MultiConnectionQueryService { } /** - * 🆕 대상 커넥션에 데이터 업데이트 + * 🆕 대상 커넥션에 데이터 업데이트 (보안상 외부 DB 비활성화) */ async updateDataToConnection( connectionId: number, @@ -296,6 +299,11 @@ export class MultiConnectionQueryService { data: Record, conditions: Record ): Promise { + // 보안상 외부 DB에 대한 UPDATE 작업은 비활성화 + if (connectionId !== 0) { + throw new Error("보안상 외부 데이터베이스에 대한 UPDATE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요."); + } + try { logger.info( `데이터 업데이트 시작: connectionId=${connectionId}, table=${tableName}` @@ -378,7 +386,7 @@ export class MultiConnectionQueryService { } /** - * 🆕 대상 커넥션에서 데이터 삭제 + * 🆕 대상 커넥션에서 데이터 삭제 (보안상 외부 DB 비활성화) */ async deleteDataFromConnection( connectionId: number, @@ -386,6 +394,11 @@ export class MultiConnectionQueryService { conditions: Record, maxDeleteCount: number = 100 ): Promise { + // 보안상 외부 DB에 대한 DELETE 작업은 비활성화 + if (connectionId !== 0) { + throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요."); + } + try { logger.info( `데이터 삭제 시작: connectionId=${connectionId}, table=${tableName}` diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 83e092c8..fa3a934d 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -76,6 +76,15 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); + + /* Z-Index 계층 구조 */ + --z-background: 1; + --z-layout: 10; + --z-content: 50; + --z-floating: 100; + --z-modal: 1000; + --z-tooltip: 2000; + --z-critical: 3000; } .dark { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index ba067c97..11470e80 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -48,6 +48,8 @@ export default function RootLayout({ + {/* Portal 컨테이너 */} +
diff --git a/frontend/components/admin/SqlQueryModal.tsx b/frontend/components/admin/SqlQueryModal.tsx index 0486566e..9ed6a494 100644 --- a/frontend/components/admin/SqlQueryModal.tsx +++ b/frontend/components/admin/SqlQueryModal.tsx @@ -117,6 +117,32 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c return; } + // SELECT 쿼리만 허용하는 검증 + const trimmedQuery = query.trim().toUpperCase(); + if (!trimmedQuery.startsWith('SELECT')) { + toast({ + title: "보안 오류", + description: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다. INSERT, UPDATE, DELETE는 허용되지 않습니다.", + variant: "destructive", + }); + return; + } + + // 위험한 키워드 검사 + const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE']; + const hasDangerousKeyword = dangerousKeywords.some(keyword => + trimmedQuery.includes(keyword) + ); + + if (hasDangerousKeyword) { + toast({ + title: "보안 오류", + description: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.", + variant: "destructive", + }); + return; + } + console.log("쿼리 실행 시작:", { connectionId, query }); setLoading(true); try { diff --git a/frontend/components/admin/UserToolbar.tsx b/frontend/components/admin/UserToolbar.tsx index ff3e455d..4c0709a5 100644 --- a/frontend/components/admin/UserToolbar.tsx +++ b/frontend/components/admin/UserToolbar.tsx @@ -116,14 +116,14 @@ export function UserToolbar({ {/* 고급 검색 필드들 */}
-
+ {/*
handleAdvancedSearchChange("search_sabun", e.target.value)} /> -
+
*/}
diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index bbd06f58..96a9a76c 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -243,7 +243,7 @@ export const EditModal: React.FC = ({ minHeight: dynamicSize.height, maxWidth: "95vw", maxHeight: "95vh", - zIndex: 9999, // 모든 컴포넌트보다 위에 표시 + zIndex: 1000, // 모든 컴포넌트보다 위에 표시 }} data-radix-portal="true" > @@ -251,7 +251,7 @@ export const EditModal: React.FC = ({ 수정 -
+
{loading ? (
@@ -282,7 +282,7 @@ export const EditModal: React.FC = ({ left: component.position?.x || 0, width: component.size?.width || 200, height: component.size?.height || 40, - zIndex: component.position?.z || (1000 + index), // 모달 내부에서 충분히 높은 z-index + zIndex: component.position?.z || (10 + index), // 모달 내부에서 적절한 z-index }} > {/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시를 위해) */} diff --git a/frontend/components/screen/FloatingPanel.tsx b/frontend/components/screen/FloatingPanel.tsx index ca6f8a76..c853aa7d 100644 --- a/frontend/components/screen/FloatingPanel.tsx +++ b/frontend/components/screen/FloatingPanel.tsx @@ -227,7 +227,7 @@ export const FloatingPanel: React.FC = ({
= ({ height: `${panelSize.height}px`, transform: isDragging ? "scale(1.01)" : "scale(1)", transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out", - zIndex: isDragging ? 9999 : 9998, // 항상 컴포넌트보다 위에 표시 + zIndex: isDragging ? 101 : 100, // 항상 컴포넌트보다 위에 표시 }} > {/* 헤더 */} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 8212f0d0..30227fb5 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -532,12 +532,9 @@ export const InteractiveScreenViewerDynamic: React.FC
-
- {/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */} - - {/* 위젯 렌더링 */} -
{renderInteractiveWidget(component)}
-
+ {/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */} + {/* 위젯 렌더링 */} + {renderInteractiveWidget(component)}
{/* 팝업 화면 렌더링 */} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 2e1bb86f..afb720cc 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -85,7 +85,7 @@ export const RealtimePreviewDynamic: React.FC = ({ ? { outline: "2px solid #3b82f6", outlineOffset: "2px", - zIndex: 30, // 패널(z-50)과 모달(z-50)보다 낮게 설정 + zIndex: 20, // 패널과 모달보다 낮게 설정 } : {}; @@ -125,7 +125,7 @@ export const RealtimePreviewDynamic: React.FC = ({ return (
= ({ > {/* 동적 컴포넌트 렌더링 */}
= ({ {/* 선택된 컴포넌트 정보 표시 */} {isSelected && ( -
+
{type === "widget" && ( -
+
{getWidgetIcon((component as WidgetComponent).widgetType)} - {(component as WidgetComponent).widgetType || "widget"} + {(component as WidgetComponent).widgetType || "widget"}
)} {type !== "widget" && ( -
- {component.componentConfig?.type || type} +
+ {component.componentConfig?.type || type}
)}
diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index f54af1f3..c765e788 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -3437,7 +3437,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD >
{ if (e.target === e.currentTarget && !selectionDrag.wasSelecting) { setSelectedComponent(null); @@ -3502,7 +3502,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD opacity: 0.8, transform: "scale(1.02)", transition: "none", - zIndex: 9999, + zIndex: 50, }, }; } else { @@ -3525,7 +3525,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ...component.style, opacity: 0.8, transition: "none", - zIndex: 8888, // 주 컴포넌트보다 약간 낮게 + zIndex: 40, // 주 컴포넌트보다 약간 낮게 }, }; } @@ -3610,7 +3610,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD opacity: 0.8, transform: "scale(1.02)", transition: "none", - zIndex: 9999, + zIndex: 50, }, }; } else { diff --git a/frontend/components/screen/filters/AdvancedSearchFilters.tsx b/frontend/components/screen/filters/AdvancedSearchFilters.tsx index 227f23dd..50f7b4d1 100644 --- a/frontend/components/screen/filters/AdvancedSearchFilters.tsx +++ b/frontend/components/screen/filters/AdvancedSearchFilters.tsx @@ -65,7 +65,14 @@ export const AdvancedSearchFilters: React.FC = ({ return tableColumns .filter((col) => { const webType = col.webType || col.web_type; - return filterableWebTypes.includes(webType) && col.isVisible !== false; + const columnName = col.columnName || col.column_name; + // 체크박스 컬럼과 __checkbox__ 컬럼, 사번 컬럼 제외 + return filterableWebTypes.includes(webType) && + col.isVisible !== false && + columnName !== "__checkbox__" && + !columnName.toLowerCase().includes('checkbox') && + !columnName.toLowerCase().includes('sabun') && + !columnName.toLowerCase().includes('사번'); }) .slice(0, 6) // 최대 6개까지만 자동 생성 .map((col) => ({ @@ -333,8 +340,7 @@ export const AdvancedSearchFilters: React.FC = ({ }; return ( -
- +
{renderFilter(filter)}
); diff --git a/frontend/components/screen/widgets/FileUpload.tsx b/frontend/components/screen/widgets/FileUpload.tsx index d7510675..1edab302 100644 --- a/frontend/components/screen/widgets/FileUpload.tsx +++ b/frontend/components/screen/widgets/FileUpload.tsx @@ -703,20 +703,53 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
{/* 드래그 앤 드롭 영역 */}
- -

+

+ +
+
+
+
+

{fileConfig.dragDropText || "파일을 드래그하여 업로드하세요"}

-

또는 클릭하여 파일을 선택하세요

+

+ 또는 클릭하여 파일을 선택하세요 +

- @@ -740,27 +773,53 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf {/* 업로드된 파일 목록 */} {uploadedFiles.length > 0 && ( -
-

- 첨부된 파일 ({uploadedFiles.length}/{fileConfig.maxFiles}) -

+
+
+
+
+ +
+
+

+ 업로드된 파일 ({uploadedFiles.length}/{fileConfig.maxFiles}) +

+

+ 총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))} +

+
+
+ +
-
+
{uploadedFiles.map((fileInfo) => ( -
-
- {getFileIcon(fileInfo.fileExt)} +
+
+
+ {getFileIcon(fileInfo.fileExt)} +
-

{fileInfo.realFileName}

-
- {formatFileSize(fileInfo.fileSize)} - - {fileInfo.fileExt.toUpperCase()} +

+ {fileInfo.realFileName} +

+
+ +
+ {formatFileSize(fileInfo.fileSize)} +
+ +
+ + {fileInfo.fileExt.toUpperCase()} + +
{fileInfo.writer && ( - <> - - {fileInfo.writer} - + +
+ {fileInfo.writer} +
)}
@@ -784,35 +843,60 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
-
+
{/* 상태 표시 */} - {fileInfo.isUploading && } - {fileInfo.status === "ACTIVE" && } - {fileInfo.hasError && } + {fileInfo.isUploading && ( +
+ + 업로드 중... +
+ )} + {fileInfo.status === "ACTIVE" && ( +
+ + 완료 +
+ )} + {fileInfo.hasError && ( +
+ + 오류 +
+ )} {/* 액션 버튼 */} {!fileInfo.isUploading && !fileInfo.hasError && ( - <> +
{fileConfig.showPreview && ( - )} - - - )} - + +
+ )}
))} @@ -821,8 +905,18 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf )} {/* 문서 타입 정보 */} -
- {fileConfig.docTypeName} +
+
+
+ +
+ + 파일명 클릭으로 미리보기 또는 "전체 자세히보기"로 파일 관리 + +
+ + {fileConfig.docTypeName} +
); diff --git a/frontend/components/screen/widgets/types/FileWidget.tsx b/frontend/components/screen/widgets/types/FileWidget.tsx index 4a36305b..981b9e2b 100644 --- a/frontend/components/screen/widgets/types/FileWidget.tsx +++ b/frontend/components/screen/widgets/types/FileWidget.tsx @@ -134,17 +134,22 @@ export const FileWidget: React.FC = ({ component, value,
{/* 파일 업로드 영역 */}
- -

+

+ +
+
+
+
+

{readonly ? "파일 업로드 불가" : "파일을 선택하거나 드래그하여 업로드"}

-

+

{config?.accept && `허용 형식: ${config.accept}`} {config?.maxSize && ` (최대 ${config.maxSize}MB)`}

@@ -162,42 +167,83 @@ export const FileWidget: React.FC = ({ component, value, {/* 업로드된 파일 목록 */} {files.length > 0 && ( -
- {files.map((file, index) => ( -
-
- -
-

{file.name}

- {file.size > 0 &&

{formatFileSize(file.size)}

} -
+
+
+
+
+ +
+
+

+ 업로드된 파일 ({files.length}/{config?.maxFiles || "∞"}) +

+

+ 총 {formatFileSize(files.reduce((sum, file) => sum + file.size, 0))} +

- - {!readonly && ( - - )}
- ))} +
+ +
+ {files.map((file, index) => ( +
+
+
+ +
+
+

+ {file.name} +

+ {file.size > 0 && ( +
+
+ {formatFileSize(file.size)} +
+ )} +
+
+ + {!readonly && ( + + )} +
+ ))} +
)} {/* 파일 개수 표시 */} {files.length > 0 && ( -
- - {files.length}개 파일 - - {config?.maxFiles && 최대 {config.maxFiles}개} +
+
+
+ +
+ + 파일명 클릭으로 미리보기 또는 "전체 자세히보기"로 파일 관리 + +
+
+ + {files.length}개 파일 + + {config?.maxFiles && ( + + 최대 {config.maxFiles}개 + + )} +
)} diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 21c7ed5c..b4d0cae5 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( = ({ padding: "32px", // 패딩 대폭 증가 width: "100%", height: "100%", - background: "linear-gradient(to br, #f8fafc, #f1f5f9)", // 부드러운 그라데이션 배경 + background: "#f8fafc", // 연한 하늘색 배경 (채도 낮춤) overflow: "auto", borderRadius: "12px", // 컨테이너 자체도 라운드 처리 }; @@ -336,7 +336,22 @@ export const CardDisplayComponent: React.FC = ({ <>
= ({ onDragEnd={onDragEnd} {...safeDomProps} > -
+
{displayData.length === 0 ? (
= ({
handleCardClick(data)} > {/* 카드 이미지 - 통일된 디자인 */} diff --git a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx index 1b88672e..a0d78ad8 100644 --- a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx @@ -46,10 +46,11 @@ export const SingleTableWithSticky: React.FC = ({ return (
@@ -57,12 +58,12 @@ export const SingleTableWithSticky: React.FC = ({ className="w-full" style={{ width: "100%", - maxWidth: "100%", - tableLayout: "fixed", + minWidth: "100%", + tableLayout: "auto", // 테이블 크기 자동 조정 boxSizing: "border-box", }} > - + {visibleColumns.map((column, colIndex) => { // 왼쪽 고정 컬럼들의 누적 너비 계산 @@ -84,23 +85,24 @@ export const SingleTableWithSticky: React.FC = ({ key={column.columnName} className={cn( column.columnName === "__checkbox__" - ? "h-12 border-0 px-4 py-3 text-center align-middle" - : "h-12 cursor-pointer border-0 px-4 py-3 text-left align-middle font-semibold whitespace-nowrap text-slate-700 select-none transition-all duration-200", + ? "h-12 border-0 px-6 py-4 text-center align-middle" + : "h-12 cursor-pointer border-0 px-6 py-4 text-left align-middle font-semibold whitespace-nowrap text-gray-700 select-none transition-all duration-200 hover:text-gray-900", `text-${column.align}`, - column.sortable && "hover:bg-blue-50/50 hover:text-blue-700", + column.sortable && "hover:bg-orange-200/70", // 고정 컬럼 스타일 - column.fixed === "left" && "sticky z-10 border-r border-gray-200/60 bg-gradient-to-r from-slate-50 to-blue-50/30 shadow-sm", - column.fixed === "right" && "sticky z-10 border-l border-gray-200/60 bg-gradient-to-r from-slate-50 to-blue-50/30 shadow-sm", + column.fixed === "left" && "sticky z-10 border-r border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70 shadow-sm", + column.fixed === "right" && "sticky z-10 border-l border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70 shadow-sm", // 숨김 컬럼 스타일 (디자인 모드에서만) isDesignMode && column.hidden && "bg-gray-100/50 opacity-40", )} style={{ width: getColumnWidth(column), - minWidth: getColumnWidth(column), - maxWidth: getColumnWidth(column), + minWidth: "100px", // 최소 너비 보장 + maxWidth: "300px", // 최대 너비 제한 boxSizing: "border-box", overflow: "hidden", textOverflow: "ellipsis", + whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 // sticky 위치 설정 ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), @@ -117,16 +119,12 @@ export const SingleTableWithSticky: React.FC = ({ {columnLabels[column.columnName] || column.displayName || column.columnName} - {column.sortable && ( + {column.sortable && sortColumn === column.columnName && ( - {sortColumn === column.columnName ? ( - sortDirection === "asc" ? ( - - ) : ( - - ) + {sortDirection === "asc" ? ( + ) : ( - + )} )} @@ -159,9 +157,9 @@ export const SingleTableWithSticky: React.FC = ({ handleRowClick(row)} @@ -185,17 +183,19 @@ export const SingleTableWithSticky: React.FC = ({ = ({ width: "100%", // 컨테이너 전체 너비 사용 maxWidth: "100%", // 최대 너비 제한 height: "auto", // 항상 자동 높이로 테이블 크기에 맞춤 - minHeight: isDesignMode ? `${Math.min(optimalHeight, 400)}px` : `${optimalHeight}px`, // 최소 높이 보장 + minHeight: isDesignMode ? "200px" : "300px", // 최소 높이만 보장 + maxHeight: isDesignMode ? "600px" : "800px", // 최대 높이 제한으로 스크롤 활성화 ...component.style, ...style, display: "flex", @@ -1315,8 +1316,12 @@ export const TableListComponent: React.FC = ({
= ({ {/* 헤더 */} {tableConfig.showHeader && (
= ({ >
{(tableConfig.title || tableLabel) && ( -

{tableConfig.title || tableLabel}

+

{tableConfig.title || tableLabel}

)}
{/* 선택된 항목 정보 표시 */} {selectedRows.size > 0 && ( -
+
{selectedRows.size}개 선택됨
)} @@ -1351,15 +1356,14 @@ export const TableListComponent: React.FC = ({ size="sm" onClick={handleRefresh} disabled={loading} - style={buttonStyle} - className="group relative rounded-lg shadow-sm [&:hover]:opacity-90" + className="group relative rounded-xl border-gray-200/60 bg-white/80 backdrop-blur-sm shadow-sm hover:shadow-md transition-all duration-200 hover:bg-gray-50/80" >
- - {loading &&
} + + {loading &&
}
- + {loading ? "새로고침 중..." : "새로고침"}
@@ -1399,7 +1403,7 @@ export const TableListComponent: React.FC = ({ {/* 테이블 컨텐츠 */}
= 50 ? "flex-1" : ""}`} + className={`w-full overflow-auto flex-1`} style={{ width: "100%", maxWidth: "100%", @@ -1478,20 +1482,20 @@ export const TableListComponent: React.FC = ({ />
) : ( - // 기존 테이블 (가로 스크롤이 필요 없는 경우) -
+ // 기존 테이블 (가로 스크롤이 필요한 경우) +
= ({ = ({ boxSizing: "border-box", overflow: "hidden", textOverflow: "ellipsis", + whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 }} className={cn( - "h-12 px-4 py-3 align-middle text-sm font-semibold text-gray-800", + "h-12 px-6 py-4 align-middle text-sm font-semibold text-gray-700", + "transition-colors duration-200 ease-out", column.columnName === "__checkbox__" ? "text-center" - : "cursor-pointer whitespace-nowrap select-none", + : "cursor-pointer whitespace-nowrap select-none hover:text-gray-900", `text-${column.align}`, - column.sortable && "transition-colors duration-150 hover:bg-orange-100", + column.sortable && "hover:bg-orange-200/70", )} onClick={() => column.sortable && handleSort(column.columnName)} > @@ -1532,16 +1540,12 @@ export const TableListComponent: React.FC = ({ ) : (
{columnLabels[column.columnName] || column.displayName} - {column.sortable && ( + {column.sortable && sortColumn === column.columnName && (
- {sortColumn === column.columnName ? ( - sortDirection === "asc" ? ( - - ) : ( - - ) + {sortDirection === "asc" ? ( + ) : ( - + )}
)} @@ -1572,14 +1576,14 @@ export const TableListComponent: React.FC = ({ onDragStart={(e) => handleRowDragStart(e, row, index)} onDragEnd={handleRowDragEnd} className={cn( - "group relative h-12 cursor-pointer border-b border-gray-100 transition-all duration-200", + "group relative h-12 cursor-pointer border-b border-gray-100/60 transition-all duration-200", // 기본 스타일 tableConfig.tableStyle?.hoverEffect && - "hover:bg-gradient-to-r hover:from-orange-200 hover:to-orange-300/90 hover:shadow-sm", - tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-100/80", + "hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60 hover:shadow-sm", + tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/40", // 드래그 상태 스타일 (미묘하게) draggedRowIndex === index && - "border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100/40 shadow-sm", + "border-blue-200/60 bg-gradient-to-r from-blue-50/60 to-indigo-50/40 shadow-sm", isDragging && draggedRowIndex !== index && "opacity-70", // 드래그 가능 표시 !isDesignMode && "hover:cursor-grab active:cursor-grabbing", @@ -1597,14 +1601,16 @@ export const TableListComponent: React.FC = ({ = ({ // 데이터는 useEffect에서 자동으로 다시 로드됨 }} - className="rounded-lg border border-slate-200 bg-white/80 px-3 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:bg-white" + className="rounded-xl border border-gray-200/60 bg-white/80 backdrop-blur-sm px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300/60 hover:bg-white hover:shadow-md" > {(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => (