From 4a0c42d80c81011f30cc531cdf601503a4b5d60a Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Sep 2025 15:23:12 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/tableManagementController.ts | 68 + .../src/routes/tableManagementRoutes.ts | 7 + .../src/services/tableManagementService.ts | 101 ++ docs/화면관리_시스템_설계.md | 918 ++++++++++++- .../components/screen/DesignerToolbar.tsx | 28 +- .../screen/InteractiveDataTable.tsx | 438 +++++++ .../screen/InteractiveScreenViewer.tsx | 52 +- .../components/screen/RealtimePreview.tsx | 391 +++++- frontend/components/screen/ScreenDesigner.tsx | 305 ++++- .../screen/panels/DataTableConfigPanel.tsx | 1138 +++++++++++++++++ .../screen/panels/PropertiesPanel.tsx | 96 +- .../screen/panels/TemplatesPanel.tsx | 170 +++ frontend/lib/api/screen.ts | 29 + frontend/types/screen.ts | 58 +- 14 files changed, 3757 insertions(+), 42 deletions(-) create mode 100644 frontend/components/screen/InteractiveDataTable.tsx create mode 100644 frontend/components/screen/panels/DataTableConfigPanel.tsx create mode 100644 frontend/components/screen/panels/TemplatesPanel.tsx diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 520c8f42..09d7dbe3 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -439,3 +439,71 @@ export async function updateColumnWebType( res.status(500).json(response); } } + +/** + * 테이블 데이터 조회 (페이징 + 검색) + */ +export async function getTableData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { page = 1, size = 10, search = {}, sortBy, sortOrder = 'asc' } = req.body; + + logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); + logger.info(`페이징: page=${page}, size=${size}`); + logger.info(`검색 조건:`, search); + logger.info(`정렬: ${sortBy} ${sortOrder}`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + + // 데이터 조회 + const result = await tableManagementService.getTableData( + tableName, + { + page: parseInt(page), + size: parseInt(size), + search, + sortBy, + sortOrder + } + ); + + logger.info(`테이블 데이터 조회 완료: ${tableName}, 총 ${result.total}건, 페이지 ${result.page}/${result.totalPages}`); + + const response: ApiResponse = { + success: true, + message: "테이블 데이터를 성공적으로 조회했습니다.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 데이터 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 데이터 조회 중 오류가 발생했습니다.", + error: { + code: "TABLE_DATA_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 9851eb43..83e32656 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -8,6 +8,7 @@ import { getTableLabels, getColumnLabels, updateColumnWebType, + getTableData, } from "../controllers/tableManagementController"; const router = express.Router(); @@ -63,4 +64,10 @@ router.put( updateColumnWebType ); +/** + * 테이블 데이터 조회 (페이징 + 검색) + * POST /api/table-management/tables/:tableName/data + */ +router.post("/tables/:tableName/data", getTableData); + export default router; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 74412e3b..124a04f0 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -514,4 +514,105 @@ export class TableManagementService { return {}; } } + + /** + * 테이블 데이터 조회 (페이징 + 검색) + */ + async getTableData( + tableName: string, + options: { + page: number; + size: number; + search?: Record; + sortBy?: string; + sortOrder?: string; + } + ): Promise<{ + data: any[]; + total: number; + page: number; + size: number; + totalPages: number; + }> { + try { + const { page, size, search = {}, sortBy, sortOrder = 'asc' } = options; + const offset = (page - 1) * size; + + logger.info(`테이블 데이터 조회: ${tableName}`, options); + + // WHERE 조건 구성 + let whereConditions: string[] = []; + let searchValues: any[] = []; + let paramIndex = 1; + + if (search && Object.keys(search).length > 0) { + for (const [column, value] of Object.entries(search)) { + if (value !== null && value !== undefined && value !== '') { + // 안전한 컬럼명 검증 (SQL 인젝션 방지) + const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ''); + + if (typeof value === 'string') { + whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`); + searchValues.push(`%${value}%`); + } else { + whereConditions.push(`${safeColumn} = $${paramIndex}`); + searchValues.push(value); + } + paramIndex++; + } + } + } + + const whereClause = whereConditions.length > 0 + ? `WHERE ${whereConditions.join(' AND ')}` + : ''; + + // ORDER BY 조건 구성 + let orderClause = ''; + if (sortBy) { + const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, ''); + const safeSortOrder = sortOrder.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`; + } + + // 안전한 테이블명 검증 + const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ''); + + // 전체 개수 조회 + const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`; + const countResult = await prisma.$queryRawUnsafe(countQuery, ...searchValues); + const total = parseInt(countResult[0].count); + + // 데이터 조회 + const dataQuery = ` + SELECT * FROM ${safeTableName} + ${whereClause} + ${orderClause} + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const data = await prisma.$queryRawUnsafe( + dataQuery, + ...searchValues, + size, + offset + ); + + const totalPages = Math.ceil(total / size); + + logger.info(`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`); + + return { + data, + total, + page, + size, + totalPages + }; + + } catch (error) { + logger.error(`테이블 데이터 조회 오류: ${tableName}`, error); + throw error; + } + } } diff --git a/docs/화면관리_시스템_설계.md b/docs/화면관리_시스템_설계.md index 68d5b6b7..34fa3ccc 100644 --- a/docs/화면관리_시스템_설계.md +++ b/docs/화면관리_시스템_설계.md @@ -35,25 +35,31 @@ #### ✅ 완료된 주요 기능들 - **컴포넌트 관리 시스템**: 드래그앤드롭, 다중 선택, 그룹 드래그, 실시간 위치 업데이트 -- **속성 편집 시스템**: 실시간 속성 편집, 라벨 관리 (텍스트, 폰트, 색상, 여백), 필수 입력 시 주황색 \* 표시 +- **⚡ 실시간 속성 편집 시스템**: 로컬 상태 기반 즉시 반영, 완벽한 입력/체크박스 실시간 업데이트 +- **속성 편집 시스템**: 라벨 관리 (텍스트, 폰트, 색상, 여백), 필수 입력 시 주황색 \* 표시 - **격자 시스템**: 동적 격자 설정, 컴포넌트 스냅 및 크기 조정 - **패널 관리**: 플로팅 패널, 수동 크기 조정, 위치 기억 - **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file +- **데이터 테이블 컴포넌트**: 완전한 실시간 설정 시스템, 컬럼 관리, 필터링, 페이징 +- **🆕 실시간 데이터 테이블**: 실제 PostgreSQL 데이터 조회, 웹타입별 검색 필터, 페이지네이션, 데이터 포맷팅 #### 🔧 해결된 기술적 문제들 +- **⚡ 실시간 속성 편집 완성**: 로컬 상태 기반 이중 관리 시스템으로 완벽한 실시간 반영 +- **체크박스 실시간 업데이트**: 모든 체크박스의 즉시 상태 변경 및 유지 +- **동적 컴포넌트 상태 관리**: ID 기반 컬럼별 개별 상태 관리 및 동기화 - **라벨 하단 여백 동적 적용**: 여백값에 따른 정확한 위치 계산 - **스타일 속성 개별 업데이트**: 초기화 방지를 위한 `style.propertyName` 방식 적용 -- **체크박스 실시간 반영**: 로컬 상태 + 실제 속성 동시 업데이트 - **다중 드래그 최적화**: 지연 없는 실시간 미리보기, 선택 해제 방지 -- **입력 필드 실시간 적용**: debounce 제거, 즉시 반영 시스템 +- **입력값 보존 시스템**: 패널 재오픈해도 사용자 입력값 완벽 유지 #### 🎯 개발 진행 상황 -- **현재 완성도**: 95% (핵심 기능 완료) +- **현재 완성도**: 98% (실시간 편집 시스템 완성, 핵심 기능 완료) - **기술 스택**: Next.js 15.4.4, TypeScript, Tailwind CSS, Shadcn/ui -- **상태 관리**: React Hooks 기반 로컬 상태 + 실시간 업데이트 패턴 +- **⚡ 상태 관리**: **완성된 실시간 속성 편집 패턴** - 로컬 상태 + 글로벌 상태 이중 관리 - **드래그앤드롭**: HTML5 Drag & Drop API 기반 고도화된 시스템 +- **🎯 표준화**: 모든 속성 편집 컴포넌트에 실시간 패턴 적용 완료 ### 🎯 **현재 테이블 구조와 100% 호환** @@ -182,6 +188,187 @@ 4. **설정 저장**: 화면 정의를 데이터베이스에 저장 5. **런타임 생성**: 실제 서비스 화면을 동적으로 생성 +## ⚡ 실시간 속성 편집 시스템 + +### 개요 + +화면관리 시스템의 핵심 기능 중 하나인 실시간 속성 편집은 사용자가 컴포넌트의 속성을 수정할 때 즉시 화면에 반영되는 시스템입니다. 이 시스템은 **로컬 상태 기반 입력 관리**와 **실시간 업데이트 패턴**을 통해 구현되었습니다. + +### 🎯 핵심 아키텍처 패턴 + +#### 1. 로컬 상태 + 글로벌 상태 이중 관리 + +```typescript +// 1단계: 로컬 상태 정의 (실시간 표시용) +const [localInputs, setLocalInputs] = useState({ + title: component.title || "", + placeholder: component.placeholder || "", + // 모든 입력 필드의 현재 값 +}); + +const [localValues, setLocalValues] = useState({ + showButton: component.showButton ?? true, + enabled: component.enabled ?? false, + // 모든 체크박스의 현재 상태 +}); + +// 2단계: 컴포넌트 변경 시 자동 동기화 +useEffect(() => { + setLocalInputs({ + title: component.title || "", + placeholder: component.placeholder || "", + }); + + setLocalValues({ + showButton: component.showButton ?? true, + enabled: component.enabled ?? false, + }); +}, [component.title, component.placeholder, component.showButton]); +``` + +#### 2. 실시간 입력 처리 패턴 + +```typescript +// 텍스트 입력 - 즉시 반영 + { + const newValue = e.target.value; + // 1) 로컬 상태 즉시 업데이트 (화면 반영) + setLocalInputs(prev => ({ ...prev, title: newValue })); + // 2) 글로벌 상태 업데이트 (데이터 저장) + onUpdateProperty("title", newValue); + }} +/> + +// 체크박스 - 즉시 반영 + { + // 1) 로컬 상태 즉시 업데이트 + setLocalValues(prev => ({ ...prev, showButton: checked as boolean })); + // 2) 글로벌 상태 업데이트 + onUpdateProperty("showButton", checked); + }} +/> +``` + +#### 3. 동적 컴포넌트별 상태 관리 + +```typescript +// 컬럼별 개별 상태 관리 (ID 기반) +const [localColumnInputs, setLocalColumnInputs] = useState< + Record +>({}); +const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState< + Record +>({}); + +// 기존 값 보존하면서 새 항목만 추가 +useEffect(() => { + setLocalColumnInputs((prev) => { + const newInputs = { ...prev }; + component.columns?.forEach((col) => { + if (!(col.id in newInputs)) { + // 기존 입력값 보존 + newInputs[col.id] = col.label; + } + }); + return newInputs; + }); +}, [component.columns]); + +// 동적 입력 처리 + { + const newValue = e.target.value; + setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue })); + updateColumn(column.id, { label: newValue }); + }} +/>; +``` + +### 🔧 구현 표준 가이드라인 + +#### 필수 구현 패턴 + +1. **로컬 우선 원칙**: 모든 입력은 로컬 상태를 먼저 업데이트 +2. **즉시 반영**: 로컬 상태 업데이트와 동시에 컴포넌트 속성 업데이트 +3. **기존값 보존**: useEffect에서 기존 로컬 입력값이 있으면 덮어쓰지 않음 +4. **완전한 정리**: 항목 삭제 시 관련된 모든 로컬 상태도 함께 정리 +5. **타입 안전성**: 모든 상태에 정확한 TypeScript 타입 지정 + +#### 항목 추가/삭제 시 상태 관리 + +```typescript +// 추가 시 +const addItem = useCallback( + (newItem) => { + // 로컬 상태에 즉시 추가 + setLocalColumnInputs((prev) => ({ + ...prev, + [newItem.id]: newItem.label, + })); + + setLocalColumnCheckboxes((prev) => ({ + ...prev, + [newItem.id]: { visible: true, sortable: true, searchable: true }, + })); + + // 실제 컴포넌트 업데이트 + onUpdateComponent({ items: [...component.items, newItem] }); + }, + [component.items, onUpdateComponent] +); + +// 삭제 시 +const removeItem = useCallback( + (itemId) => { + // 로컬 상태에서 제거 + setLocalColumnInputs((prev) => { + const newInputs = { ...prev }; + delete newInputs[itemId]; + return newInputs; + }); + + setLocalColumnCheckboxes((prev) => { + const newCheckboxes = { ...prev }; + delete newCheckboxes[itemId]; + return newCheckboxes; + }); + + // 실제 컴포넌트 업데이트 + const updatedItems = component.items.filter((item) => item.id !== itemId); + onUpdateComponent({ items: updatedItems }); + }, + [component.items, onUpdateComponent] +); +``` + +### 📊 적용 범위 + +이 패턴은 화면관리 시스템의 다음 컴포넌트들에 적용되었습니다: + +- **PropertiesPanel**: 기본 속성 편집 (위치, 크기, 라벨 등) +- **DataTableConfigPanel**: 데이터 테이블 상세 설정 +- **DateTypeConfigPanel**: 날짜 타입 상세 설정 +- **NumberTypeConfigPanel**: 숫자 타입 상세 설정 +- **SelectTypeConfigPanel**: 선택박스 타입 상세 설정 +- **TextTypeConfigPanel**: 텍스트 타입 상세 설정 +- **기타 모든 웹타입별 설정 패널들** + +### 🎯 사용자 경험 향상 효과 + +- **🚀 즉시 피드백**: 타이핑하는 순간 화면에 바로 반영 +- **🔄 상태 일관성**: 패널을 닫았다 열어도 입력한 값이 정확히 유지 +- **⚡ 빠른 반응성**: 지연 없는 실시간 UI 업데이트 +- **🛡️ 안정성**: 메모리 누수 없는 완전한 상태 관리 + ## 🚀 핵심 기능 ### 1. 화면 설계기 (Screen Designer) @@ -1760,6 +1947,271 @@ export default function GridItem({ ## ⚙️ 백엔드 구현 +### 🆕 데이터 테이블 실시간 조회 API (2025.09 추가) + +#### 1. 테이블 데이터 조회 API + +**라우트 설정** + +```typescript +// tableManagementRoutes.ts +/** + * 테이블 데이터 조회 (페이징 + 검색) + * POST /api/table-management/tables/:tableName/data + */ +router.post("/tables/:tableName/data", getTableData); +``` + +**컨트롤러 구현** + +```typescript +// tableManagementController.ts +export async function getTableData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { + page = 1, + size = 10, + search = {}, + sortBy, + sortOrder = "asc", + } = req.body; + + logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); + logger.info(`페이징: page=${page}, size=${size}`); + logger.info(`검색 조건:`, search); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { code: "MISSING_TABLE_NAME" }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + + // 데이터 조회 + const result = await tableManagementService.getTableData(tableName, { + page: parseInt(page), + size: parseInt(size), + search, + sortBy, + sortOrder, + }); + + const response: ApiResponse = { + success: true, + message: "테이블 데이터를 성공적으로 조회했습니다.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 데이터 조회 중 오류 발생:", error); + // 오류 응답 처리 + } +} +``` + +**서비스 로직** + +```typescript +// tableManagementService.ts +export class TableManagementService { + /** + * 테이블 데이터 조회 (페이징 + 검색) + */ + async getTableData( + tableName: string, + options: { + page: number; + size: number; + search?: Record; + sortBy?: string; + sortOrder?: string; + } + ): Promise<{ + data: any[]; + total: number; + page: number; + size: number; + totalPages: number; + }> { + try { + const { page, size, search = {}, sortBy, sortOrder = "asc" } = options; + const offset = (page - 1) * size; + + // WHERE 조건 구성 (SQL 인젝션 방지) + let whereConditions: string[] = []; + let searchValues: any[] = []; + let paramIndex = 1; + + if (search && Object.keys(search).length > 0) { + for (const [column, value] of Object.entries(search)) { + if (value !== null && value !== undefined && value !== "") { + // 안전한 컬럼명 검증 + const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); + + if (typeof value === "string") { + whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`); + searchValues.push(`%${value}%`); + } else { + whereConditions.push(`${safeColumn} = $${paramIndex}`); + searchValues.push(value); + } + paramIndex++; + } + } + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // ORDER BY 조건 구성 + let orderClause = ""; + if (sortBy) { + const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, ""); + const safeSortOrder = + sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC"; + orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`; + } + + // 안전한 테이블명 검증 + const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); + + // 전체 개수 조회 + const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`; + const countResult = await prisma.$queryRawUnsafe( + countQuery, + ...searchValues + ); + const total = parseInt(countResult[0].count); + + // 데이터 조회 + const dataQuery = ` + SELECT * FROM ${safeTableName} + ${whereClause} + ${orderClause} + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const data = await prisma.$queryRawUnsafe( + dataQuery, + ...searchValues, + size, + offset + ); + + const totalPages = Math.ceil(total / size); + + return { + data, + total, + page, + size, + totalPages, + }; + } catch (error) { + logger.error(`테이블 데이터 조회 오류: ${tableName}`, error); + throw error; + } + } +} +``` + +#### 2. API 응답 형식 + +**성공 응답** + +```json +{ + "success": true, + "message": "테이블 데이터를 성공적으로 조회했습니다.", + "data": { + "data": [ + { + "objid": 1, + "target_objid": 12345, + "approval_seq": "A001", + "regdate": "2025-09-03T05:39:14.000Z", + "status": "pending" + } + ], + "total": 1, + "page": 1, + "size": 5, + "totalPages": 1 + } +} +``` + +**요청 형식** + +```json +{ + "page": 1, + "size": 10, + "search": { + "approval_seq": "A001", + "status": "pending" + }, + "sortBy": "regdate", + "sortOrder": "desc" +} +``` + +#### 3. 보안 및 성능 최적화 + +**SQL 인젝션 방지** + +- 정규표현식을 통한 안전한 컬럼명/테이블명 검증 +- 파라미터 바인딩 사용 (`$queryRawUnsafe` with parameters) +- 사용자 입력값 필터링 + +**성능 최적화** + +- 페이징 처리로 대용량 데이터 대응 +- COUNT 쿼리와 데이터 쿼리 분리 +- 인덱스 기반 정렬 지원 + +**에러 처리** + +- 상세한 로깅 시스템 +- 사용자 친화적 오류 메시지 +- HTTP 상태 코드 준수 + +#### 4. 도커 환경 통합 + +**개발 환경 설정** + +```bash +# 백엔드 컨테이너 재빌드 (새 API 반영) +docker-compose -f docker/dev/docker-compose.backend.mac.yml down backend +docker-compose -f docker/dev/docker-compose.backend.mac.yml up --build -d backend + +# API 테스트 +curl -X POST http://localhost:8080/api/table-management/tables/approval/data \ + -H "Content-Type: application/json" \ + -d '{"page": 1, "size": 5}' +``` + +**환경 변수** + +```env +# backend-node/.env +PORT=8080 +DATABASE_URL=postgresql://postgres:password@localhost:5432/ilshin +NODE_ENV=development +``` + ### 1. 화면 관리 서비스 ```typescript @@ -2376,6 +2828,350 @@ export class TableTypeIntegrationService { - **저장 기능**: 입력된 데이터를 수집하여 저장 처리 - **메뉴 연동**: 메뉴 클릭 시 할당된 인터랙티브 화면으로 자동 이동 +### 🆕 6. 실시간 데이터 테이블 (2025.09 추가) + +#### **InteractiveDataTable 컴포넌트** + +실제 화면에서 동작하는 완전한 데이터 테이블 구현 + +**핵심 기능** + +- **실시간 데이터 조회**: PostgreSQL 데이터베이스에서 직접 데이터 로드 +- **페이지네이션**: 대용량 데이터 효율적 탐색 (페이지당 항목 수 설정 가능) +- **다중 검색 필터**: 웹타입별 맞춤형 검색 UI (text, number, date, select 등) +- **정렬 기능**: 컬럼별 오름차순/내림차순 정렬 지원 +- **반응형 레이아웃**: 격자 시스템 기반 컬럼 너비 조정 + +**구현 코드** + +```typescript +// InteractiveDataTable.tsx +export const InteractiveDataTable: React.FC = ({ + component, + className = "", + style = {}, +}) => { + const [data, setData] = useState[]>([]); + const [loading, setLoading] = useState(false); + const [searchValues, setSearchValues] = useState>({}); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + + // 데이터 로드 함수 + const loadData = useCallback( + async (page: number = 1, searchParams: Record = {}) => { + if (!component.tableName) return; + + setLoading(true); + try { + const result = await tableTypeApi.getTableData(component.tableName, { + page, + size: component.pagination?.pageSize || 10, + search: searchParams, + }); + + setData(result.data); + setTotal(result.total); + setTotalPages(result.totalPages); + setCurrentPage(result.page); + } catch (error) { + console.error("❌ 테이블 데이터 조회 실패:", error); + setData([]); + } finally { + setLoading(false); + } + }, + [component.tableName, component.pagination?.pageSize] + ); + + // 검색 실행 + const handleSearch = useCallback(() => { + loadData(1, searchValues); + }, [searchValues, loadData]); + + return ( +
+ {/* 헤더: 제목, 로딩 상태, 검색 버튼 */} +
+
+

+ {component.title || component.label} + {loading && ( + + 로딩중... + + )} +

+
+ {component.showSearchButton && ( + + )} +
+
+ + {/* 검색 필터 */} + {component.filters?.length > 0 && ( +
+
검색 필터
+
`${filter.gridColumns || 3}fr`) + .join(" "), + }} + > + {component.filters.map((filter) => renderSearchFilter(filter))} +
+
+ )} +
+ + {/* 테이블 데이터 */} +
+ {/* 헤더 행 */} +
col.visible) + .map((col) => `${col.gridColumns || 2}fr`) + .join(" "), + }} + > + {component.columns + ?.filter((col) => col.visible) + .map((column) => ( +
+ {column.label} +
+ ))} +
+ + {/* 데이터 행들 */} + {loading ? ( +
+ 데이터를 불러오는 중... +
+ ) : data.length > 0 ? ( + data.map((row, rowIndex) => ( +
col.visible) + .map((col) => `${col.gridColumns || 2}fr`) + .join(" "), + }} + > + {component.columns + ?.filter((col) => col.visible) + .map((column) => ( +
+ {formatCellValue(row[column.columnName], column)} +
+ ))} +
+ )) + ) : ( +
+ 검색 결과가 없습니다 +
+ )} +
+ + {/* 페이지네이션 */} + {component.pagination?.enabled && totalPages > 1 && ( +
+
+ {component.pagination.showPageInfo && ( +
+ 총 {total.toLocaleString()}개 중{" "} + {((currentPage - 1) * pageSize + 1).toLocaleString()}- + {Math.min(currentPage * pageSize, total).toLocaleString()} +
+ )} +
+ + + {currentPage} / {totalPages} + + +
+
+
+ )} +
+ ); +}; +``` + +**데이터 포맷팅** + +```typescript +// 셀 값 포맷팅 +const formatCellValue = (value: any, column: DataTableColumn) => { + if (value === null || value === undefined) return ""; + + switch (column.widgetType) { + case "date": + if (value) { + try { + const date = new Date(value); + return date.toLocaleDateString("ko-KR"); + } catch { + return value; + } + } + break; + + case "datetime": + if (value) { + try { + const date = new Date(value); + return date.toLocaleString("ko-KR"); + } catch { + return value; + } + } + break; + + case "number": + case "decimal": + if (typeof value === "number") { + return value.toLocaleString(); + } + break; + + default: + return String(value); + } + + return String(value); +}; +``` + +**InteractiveScreenViewer 통합** + +```typescript +// InteractiveScreenViewer.tsx +const renderInteractiveWidget = (comp: ComponentData) => { + // 데이터 테이블 컴포넌트 처리 + if (comp.type === "datatable") { + return ( + + ); + } + + // 기존 위젯 처리... +}; +``` + +#### **사용자 경험 (UX)** + +**1. 관리자 (화면 설계)** + +1. 드래그앤드롭으로 데이터 테이블 컴포넌트 배치 +2. 속성 패널에서 테이블 선택 및 컬럼 설정 +3. 검색 필터 추가 및 페이지네이션 설정 +4. 실시간 미리보기로 결과 확인 + +**2. 사용자 (실제 화면)** + +1. 메뉴를 통해 할당된 화면 접근 +2. 검색 필터로 원하는 데이터 필터링 +3. 페이지네이션으로 대용량 데이터 탐색 +4. 실시간 데이터 로딩 및 정렬 + +#### **🎨 Shadcn UI 기반 모던 디자인** + +**핵심 컴포넌트** + +- **Card**: 전체 테이블을 감싸는 메인 컨테이너 +- **Table**: Shadcn Table 컴포넌트로 표준화된 테이블 UI +- **Badge**: 로딩 상태 및 필터 개수 표시 +- **Button**: 일관된 액션 버튼 디자인 +- **Separator**: 섹션 구분선 + +**디자인 특징** + +```typescript +// 메인 카드 레이아웃 + + + {/* 아이콘 + 제목 + 액션 버튼들 */} +
+ + {title} + {loading && ( + + + 로딩중... + + )} +
+
+ + {/* Shadcn Table 사용 */} + + + + + {columns.map((column) => ( + {column.label} + ))} + + + {/* 로딩, 데이터, 빈 상태 처리 */} +
+
+
+``` + +**시각적 개선사항** + +- **아이콘 통합**: Lucide React 아이콘으로 시각적 일관성 +- **로딩 애니메이션**: 스피너 아이콘으로 실시간 피드백 +- **상태별 메시지**: 빈 데이터, 로딩, 에러 상태별 적절한 UI +- **호버 효과**: 테이블 행 호버 시 시각적 피드백 +- **반응형 버튼**: 아이콘 + 텍스트 조합으로 명확한 액션 표시 + +#### **기술적 특징** + +- **성능 최적화**: React.useMemo를 활용한 메모이제이션 +- **보안**: SQL 인젝션 방지 및 입력값 검증 +- **확장성**: 웹타입별 검색 필터 및 데이터 포맷터 +- **반응형**: CSS Grid 기반 유연한 레이아웃 +- **접근성**: Shadcn UI의 WAI-ARIA 표준 준수 +- **타입 안전성**: TypeScript 완전 지원 + ## 🚀 다음 단계 계획 ### 1. 웹타입별 상세 설정 기능 구현 (진행 예정) @@ -2437,7 +3233,7 @@ interface SelectTypeConfig { 컴포넌트 데이터에 webTypeConfig 포함하여 레이아웃 저장 시 설정값도 함께 저장 -### 2. 컴포넌트 그룹화 기능 (완료) +### 2. 컴포넌트 그룹화 기능 (완료)0 - [x] 여러 위젯을 컨테이너로 그룹화 - [x] 부모-자식 관계 설정(parentId) @@ -2533,24 +3329,100 @@ interface SelectTypeConfig { ## 🔧 핵심 기술적 구현 패턴 -### 1. 상태 관리 패턴 +### 1. ⚡ 실시간 속성 편집 패턴 (핵심 표준) -#### 로컬 상태 + 실시간 업데이트 패턴 +#### 완성된 로컬 상태 + 글로벌 상태 이중 관리 시스템 -PropertiesPanel에서 사용하는 입력 필드 관리 방식: +**모든 속성 편집 컴포넌트의 표준 패턴:** ```typescript +// 1단계: 로컬 상태 정의 (실시간 표시용) const [localInputs, setLocalInputs] = useState({ - placeholder: selectedComponent?.placeholder || "", - // ... 기타 필드들 + title: component.title || "", + placeholder: component.placeholder || "", + // 모든 입력 필드의 현재 값 }); -// 입력 시 로컬 상태 + 실제 컴포넌트 동시 업데이트 -onChange={(e) => { - const newValue = e.target.value; - setLocalInputs((prev) => ({ ...prev, fieldName: newValue })); - onUpdateProperty("fieldName", newValue); -}} +const [localValues, setLocalValues] = useState({ + showButton: component.showButton ?? true, + enabled: component.enabled ?? false, + // 모든 체크박스의 현재 상태 +}); + +// 2단계: 컴포넌트 변경 시 자동 동기화 +useEffect(() => { + setLocalInputs({ + title: component.title || "", + placeholder: component.placeholder || "", + }); + + setLocalValues({ + showButton: component.showButton ?? true, + enabled: component.enabled ?? false, + }); +}, [component.title, component.placeholder, component.showButton]); + +// 3단계: 실시간 입력 처리 - 즉시 반영 + { + const newValue = e.target.value; + // 1) 로컬 상태 즉시 업데이트 (화면 반영) + setLocalInputs(prev => ({ ...prev, title: newValue })); + // 2) 글로벌 상태 업데이트 (데이터 저장) + onUpdateProperty("title", newValue); + }} +/> + + { + // 1) 로컬 상태 즉시 업데이트 + setLocalValues(prev => ({ ...prev, showButton: checked as boolean })); + // 2) 글로벌 상태 업데이트 + onUpdateProperty("showButton", checked); + }} +/> +``` + +#### 동적 컴포넌트별 상태 관리 (ID 기반) + +```typescript +// 컬럼별 개별 상태 관리 +const [localColumnInputs, setLocalColumnInputs] = useState< + Record +>({}); +const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState< + Record +>({}); + +// 기존 값 보존하면서 새 항목만 추가 +useEffect(() => { + setLocalColumnInputs((prev) => { + const newInputs = { ...prev }; + component.columns?.forEach((col) => { + if (!(col.id in newInputs)) { + // 기존 입력값 보존 + newInputs[col.id] = col.label; + } + }); + return newInputs; + }); +}, [component.columns]); + +// 동적 입력 처리 + { + const newValue = e.target.value; + setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue })); + updateColumn(column.id, { label: newValue }); + }} +/>; ``` #### 스타일 속성 개별 업데이트 패턴 @@ -2565,6 +3437,18 @@ onUpdateProperty("style", { ...selectedComponent.style, newProp: value }); onUpdateProperty("style.labelFontSize", value); ``` +### 📋 적용된 컴포넌트 목록 + +이 패턴이 완벽하게 적용된 컴포넌트들: + +- **PropertiesPanel**: 기본 속성 편집 +- **DataTableConfigPanel**: 데이터 테이블 상세 설정 +- **DateTypeConfigPanel**: 날짜 타입 상세 설정 +- **NumberTypeConfigPanel**: 숫자 타입 상세 설정 +- **SelectTypeConfigPanel**: 선택박스 타입 상세 설정 +- **TextTypeConfigPanel**: 텍스트 타입 상세 설정 +- **기타 모든 웹타입별 설정 패널들** + ### 2. 드래그앤드롭 패턴 #### 다중 컴포넌트 드래그 처리 diff --git a/frontend/components/screen/DesignerToolbar.tsx b/frontend/components/screen/DesignerToolbar.tsx index 9b95a98e..520b1abc 100644 --- a/frontend/components/screen/DesignerToolbar.tsx +++ b/frontend/components/screen/DesignerToolbar.tsx @@ -3,7 +3,20 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Menu, Database, Settings, Palette, Grid3X3, Save, Undo, Redo, Play, ArrowLeft, Cog } from "lucide-react"; +import { + Menu, + Database, + Settings, + Palette, + Grid3X3, + Save, + Undo, + Redo, + Play, + ArrowLeft, + Cog, + Layout, +} from "lucide-react"; import { cn } from "@/lib/utils"; interface DesignerToolbarProps { @@ -75,6 +88,19 @@ export const DesignerToolbar: React.FC = ({ + + + )} + + + + + {/* 검색 필터 */} + {searchFilters.length > 0 && ( + <> + +
+ + + 검색 필터 + +
`${filter.gridColumns || 3}fr`) + .join(" "), + }} + > + {searchFilters.map((filter: DataTableFilter) => ( +
+ + {renderSearchFilter(filter)} +
+ ))} +
+
+ + )} + + + {/* 테이블 내용 */} + +
+ {visibleColumns.length > 0 ? ( + <> + + + + {visibleColumns.map((column: DataTableColumn) => ( + + {column.label} + + ))} + + + + {loading ? ( + + +
+ + 데이터를 불러오는 중... +
+
+
+ ) : data.length > 0 ? ( + data.map((row, rowIndex) => ( + + {visibleColumns.map((column: DataTableColumn) => ( + + {formatCellValue(row[column.columnName], column)} + + ))} + + )) + ) : ( + + +
+ +

검색 결과가 없습니다

+

검색 조건을 변경하거나 새로고침을 시도해보세요

+
+
+
+ )} +
+
+ + {/* 페이지네이션 */} + {component.pagination?.enabled && totalPages > 1 && ( +
+
+ {component.pagination.showPageInfo && ( +
+ 총 {total.toLocaleString()}개 중{" "} + {((currentPage - 1) * pageSize + 1).toLocaleString()}- + {Math.min(currentPage * pageSize, total).toLocaleString()} +
+ )} +
+ {component.pagination.showFirstLast && ( + + )} + +
+ {currentPage} + / + {totalPages} +
+ + {component.pagination.showFirstLast && ( + + )} +
+
+
+ )} + + ) : ( +
+
+ +

표시할 컬럼이 없습니다

+

테이블 설정에서 컬럼을 추가해주세요

+
+
+ )} +
+
+ + ); +}; diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index f6912b0a..df34cb11 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -14,6 +14,7 @@ import { ko } from "date-fns/locale"; import { ComponentData, WidgetComponent, + DataTableComponent, TextTypeConfig, NumberTypeConfig, DateTypeConfig, @@ -25,6 +26,7 @@ import { CodeTypeConfig, EntityTypeConfig, } from "@/types/screen"; +import { InteractiveDataTable } from "./InteractiveDataTable"; interface InteractiveScreenViewerProps { component: ComponentData; @@ -70,6 +72,20 @@ export const InteractiveScreenViewer: React.FC = ( // 실제 사용 가능한 위젯 렌더링 const renderInteractiveWidget = (comp: ComponentData) => { + // 데이터 테이블 컴포넌트 처리 + if (comp.type === "datatable") { + return ( + + ); + } + const { widgetType, label, placeholder, required, readonly, columnName } = comp; const fieldName = columnName || comp.id; const currentValue = formData[fieldName] || ""; @@ -686,18 +702,40 @@ export const InteractiveScreenViewer: React.FC = ( } // 일반 위젯 컴포넌트 + // 템플릿 컴포넌트 목록 (자체적으로 제목을 가지므로 라벨 불필요) + const templateTypes = ["datatable"]; + + // 라벨 표시 여부 계산 + const shouldShowLabel = + component.style?.labelDisplay !== false && + (component.label || component.style?.labelText) && + !templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함 + + const labelText = component.style?.labelText || component.label || ""; + + // 라벨 스타일 적용 + const labelStyle = { + fontSize: component.style?.labelFontSize || "14px", + color: component.style?.labelColor || "#374151", + fontWeight: component.style?.labelFontWeight || "500", + backgroundColor: component.style?.labelBackgroundColor || "transparent", + padding: component.style?.labelPadding || "0", + borderRadius: component.style?.labelBorderRadius || "0", + marginBottom: component.style?.labelMarginBottom || "4px", + }; + return (
- {/* 라벨이 있는 경우 표시 */} - {component.label && ( - + {/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} + {shouldShowLabel && ( +
+ {labelText} + {component.required && *} +
)} {/* 실제 위젯 */} -
{renderInteractiveWidget(component)}
+
{renderInteractiveWidget(component)}
); }; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 1be3bc4b..4bb41616 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -19,6 +19,11 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; // import { Checkbox } from "@/components/ui/checkbox"; // import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { @@ -36,6 +41,8 @@ import { Group, ChevronDown, ChevronRight, + Search, + RotateCcw, } from "lucide-react"; interface RealtimePreviewProps { @@ -665,16 +672,17 @@ export const RealtimePreview: React.FC = ({ // 사용자가 테두리를 설정했는지 확인 const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); - // 기본 선택 테두리는 사용자 테두리가 없을 때만 적용 - const defaultRingClass = hasCustomBorder - ? "" - : isSelected - ? "ring-opacity-50 ring-2 ring-blue-500" - : "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300"; + // 기본 선택 테두리는 사용자 테두리가 없을 때만 적용 (데이터 테이블 제외) + const defaultRingClass = + hasCustomBorder || type === "datatable" + ? "" + : isSelected + ? "ring-opacity-50 ring-2 ring-blue-500" + : "hover:ring-opacity-50 hover:ring-1 hover:ring-gray-300"; - // 사용자 테두리가 있을 때 선택 상태 표시를 위한 스타일 + // 사용자 테두리가 있을 때 또는 데이터 테이블일 때 선택 상태 표시를 위한 스타일 const selectionStyle = - hasCustomBorder && isSelected + (hasCustomBorder || type === "datatable") && isSelected ? { boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)", // 외부 그림자로 선택 표시 ...style, @@ -699,6 +707,211 @@ export const RealtimePreview: React.FC = ({ borderRadius: component.style?.labelBorderRadius || "0", }; + // 데이터 테이블은 특별한 구조로 렌더링 + if (type === "datatable") { + const dataTableComponent = component as any; // DataTableComponent 타입 + + // 메모이제이션을 위한 계산 최적화 + const visibleColumns = React.useMemo( + () => dataTableComponent.columns?.filter((col: any) => col.visible) || [], + [dataTableComponent.columns], + ); + const filters = React.useMemo(() => dataTableComponent.filters || [], [dataTableComponent.filters]); + + return ( +
{ + e.stopPropagation(); + onClick?.(e); + }} + draggable + onDragStart={onDragStart} + onDragEnd={onDragEnd} + onMouseDown={(e) => { + e.stopPropagation(); + }} + > + {/* 라벨 표시 */} + {shouldShowLabel && ( +
+ {labelText} + {component.required && *} +
+ )} + + {/* Shadcn UI 기반 데이터 테이블 */} + + {/* 카드 헤더 */} + +
+
+ + {dataTableComponent.title || label} +
+
+ {filters.length > 0 && ( + + + 필터 {filters.length}개 + + )} + {dataTableComponent.showSearchButton && ( + + )} + +
+
+ + {/* 필터 영역 미리보기 */} + {filters.length > 0 && ( + <> + +
+
+ + 검색 필터 +
+
`${filter.gridColumns || 3}fr`).join(" "), + }} + > + {filters.map((filter: any, index: number) => ( +
+ +
+ 검색... +
+
+ ))} +
+
+ + )} +
+ + {/* 테이블 내용 */} + +
+ {visibleColumns.length > 0 ? ( + <> + + + + {visibleColumns.map((column: any) => ( + + {column.label} + + ))} + + + + {/* 샘플 데이터 3행 */} + + {visibleColumns.map((column: any, colIndex: number) => ( + + 샘플 데이터 1-{colIndex + 1} + + ))} + + + {visibleColumns.map((column: any, colIndex: number) => ( + + 샘플 데이터 2-{colIndex + 1} + + ))} + + + {visibleColumns.map((column: any, colIndex: number) => ( + + 샘플 데이터 3-{colIndex + 1} + + ))} + + +
+ + {/* 페이지네이션 미리보기 */} + {dataTableComponent.pagination?.enabled && ( +
+
+ {dataTableComponent.pagination.showPageInfo && ( +
+ 총 100개 중 1- + 10 +
+ )} +
+ {dataTableComponent.pagination.showFirstLast && ( + + )} + +
+ 1 + / + 10 +
+ + {dataTableComponent.pagination.showFirstLast && ( + + )} +
+
+
+ )} + + ) : ( +
+
+ +

테이블을 선택하고 컬럼을 설정하세요

+
+
+ )} +
+
+
+
+ ); + } + + // 다른 컴포넌트들은 기존 구조 사용 return (
= ({
)} + {false && + (() => { + const dataTableComponent = component as any; // DataTableComponent 타입 + const visibleColumns = dataTableComponent.columns?.filter((col: any) => col.visible) || []; + const filters = dataTableComponent.filters || []; + + return ( + <> + {/* 데이터 테이블 헤더 */} +
+
+

{dataTableComponent.title || label}

+
+ {filters.length > 0 &&
필터 {filters.length}개
} + {dataTableComponent.showSearchButton && ( + + )} +
+
+ + {/* 필터 영역 미리보기 */} + {filters.length > 0 && ( +
+
검색 필터
+
`${filter.gridColumns || 3}fr`).join(" "), + }} + > + {filters.map((filter: any, index: number) => { + const getFilterIcon = (webType: string) => { + switch (webType) { + case "text": + case "email": + case "tel": + return "📝"; + case "number": + case "decimal": + return "🔢"; + case "date": + case "datetime": + return "📅"; + case "select": + return "📋"; + default: + return "🔍"; + } + }; + + const getFilterPlaceholder = (webType: string) => { + switch (webType) { + case "text": + return "텍스트 검색..."; + case "email": + return "이메일 검색..."; + case "tel": + return "전화번호 검색..."; + case "number": + return "숫자 입력..."; + case "decimal": + return "소수 입력..."; + case "date": + return "날짜 선택..."; + case "datetime": + return "날짜시간 선택..."; + case "select": + return "옵션 선택..."; + default: + return "검색..."; + } + }; + + return ( +
+
+ {getFilterIcon(filter.widgetType)} + {filter.label} +
+
+ {getFilterPlaceholder(filter.widgetType)} +
+
+ ); + })} +
+
+ )} +
+ + {/* 테이블 내용 미리보기 */} +
+
+ {/* 테이블 헤더 행 */} + {visibleColumns.length > 0 ? ( +
`${col.gridColumns || 2}fr`).join(" "), + }} + > + {visibleColumns.map((column: any) => ( +
+ {column.label} +
+ ))} +
+ ) : ( +
테이블을 선택하고 컬럼을 설정하세요
+ )} + + {/* 샘플 데이터 행들 */} + {visibleColumns.length > 0 && + [1, 2, 3].map((row) => ( +
`${col.gridColumns || 2}fr`) + .join(" "), + }} + > + {visibleColumns.map((column: any, colIndex: number) => ( +
+ 샘플 데이터 {row}-{colIndex + 1} +
+ ))} +
+ ))} +
+
+ + {/* 페이지네이션 미리보기 */} + {dataTableComponent.pagination?.enabled && ( +
+
+ {dataTableComponent.pagination.showPageInfo && ( +
총 100개 중 1-{dataTableComponent.pagination.pageSize || 10}
+ )} +
+ {dataTableComponent.pagination.showFirstLast && ( + + )} + + 1 + + {dataTableComponent.pagination.showFirstLast && ( + + )} +
+
+
+ )} + + ); + })()} + {type === "group" && (
{/* 그룹 내용 */} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 4f8a78b3..2b9988ec 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -29,6 +29,7 @@ import { alignGroupChildrenToGrid, calculateOptimalGroupSize, normalizeGroupChildPositions, + calculateWidthFromColumns, GridSettings as GridUtilSettings, } from "@/lib/utils/gridUtils"; import { GroupingToolbar } from "./GroupingToolbar"; @@ -40,6 +41,7 @@ import { RealtimePreview } from "./RealtimePreview"; import FloatingPanel from "./FloatingPanel"; import DesignerToolbar from "./DesignerToolbar"; import TablesPanel from "./panels/TablesPanel"; +import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; import PropertiesPanel from "./panels/PropertiesPanel"; import DetailSettingsPanel from "./panels/DetailSettingsPanel"; import GridPanel from "./panels/GridPanel"; @@ -60,6 +62,14 @@ const panelConfigs: PanelConfig[] = [ defaultHeight: 700, // 테이블 목록은 그대로 유지 shortcutKey: "t", }, + { + id: "templates", + title: "템플릿", + defaultPosition: "left", + defaultWidth: 380, + defaultHeight: 700, + shortcutKey: "m", // template의 m + }, { id: "properties", title: "속성 편집", @@ -298,6 +308,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } // gridColumns 변경 시 크기 자동 업데이트 + console.log("🔍 gridColumns 변경 감지:", { + path, + value, + componentType: newComp.type, + hasGridInfo: !!gridInfo, + hasGridSettings: !!layout.gridSettings, + currentGridColumns: (newComp as any).gridColumns, + }); + if (path === "gridColumns" && gridInfo) { const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings); newComp.size = updatedSize; @@ -306,6 +325,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD oldSize: comp.size, newSize: updatedSize, }); + } else if (path === "gridColumns") { + console.log("❌ gridColumns 변경 실패:", { + hasGridInfo: !!gridInfo, + hasGridSettings: !!layout.gridSettings, + gridInfo, + gridSettings: layout.gridSettings, + }); } // 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외) @@ -414,6 +440,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setLayout(newLayout); saveToHistory(newLayout); + // selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트 + if (selectedComponent && selectedComponent.id === componentId) { + const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); + if (updatedSelectedComponent) { + console.log("🔄 selectedComponent 동기화:", { + componentId, + path, + oldColumnsCount: + selectedComponent.type === "datatable" ? (selectedComponent as any).columns?.length : "N/A", + newColumnsCount: + updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).columns?.length : "N/A", + oldFiltersCount: + selectedComponent.type === "datatable" ? (selectedComponent as any).filters?.length : "N/A", + newFiltersCount: + updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).filters?.length : "N/A", + timestamp: new Date().toISOString(), + }); + setSelectedComponent(updatedSelectedComponent); + } + } + // webTypeConfig 업데이트 후 레이아웃 상태 확인 if (path === "webTypeConfig") { const updatedComponent = newLayout.components.find((c) => c.id === componentId); @@ -574,6 +621,221 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } }, [selectedScreen?.screenId, layout]); + // 템플릿 드래그 처리 + const handleTemplateDrop = useCallback( + (e: React.DragEvent, template: TemplateComponent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const dropX = e.clientX - rect.left; + const dropY = e.clientY - rect.top; + + // 격자 스냅 적용 + const snappedPosition = + layout.gridSettings?.snapToGrid && gridInfo + ? snapToGrid({ x: dropX, y: dropY, z: 1 }, gridInfo, layout.gridSettings) + : { x: dropX, y: dropY, z: 1 }; + + console.log("🎨 템플릿 드롭:", { + templateName: template.name, + componentsCount: template.components.length, + dropPosition: { x: dropX, y: dropY }, + snappedPosition, + }); + + // 템플릿의 모든 컴포넌트들을 생성 + const newComponents: ComponentData[] = template.components.map((templateComp, index) => { + const componentId = generateComponentId(); + + // 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정 + const absoluteX = snappedPosition.x + templateComp.position.x; + const absoluteY = snappedPosition.y + templateComp.position.y; + + // 격자 스냅 적용 + const finalPosition = + layout.gridSettings?.snapToGrid && gridInfo + ? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, gridInfo, layout.gridSettings) + : { x: absoluteX, y: absoluteY, z: 1 }; + + if (templateComp.type === "container") { + return { + id: componentId, + type: "container", + label: templateComp.label, + tableName: selectedScreen?.tableName || "", + position: finalPosition, + size: templateComp.size, + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "600", + labelMarginBottom: "8px", + ...templateComp.style, + }, + }; + } else if (templateComp.type === "datatable") { + // 데이터 테이블 컴포넌트 생성 + const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) + + // gridColumns에 맞는 크기 계산 + const calculatedSize = + gridInfo && layout.gridSettings?.snapToGrid + ? (() => { + const newWidth = calculateWidthFromColumns( + gridColumns, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + return { + width: newWidth, + height: templateComp.size.height, // 높이는 템플릿 값 유지 + }; + })() + : templateComp.size; + + console.log("📊 데이터 테이블 생성 시 크기 계산:", { + gridColumns, + templateSize: templateComp.size, + calculatedSize, + hasGridInfo: !!gridInfo, + hasGridSettings: !!layout.gridSettings?.snapToGrid, + }); + + return { + id: componentId, + type: "datatable", + label: templateComp.label, + tableName: selectedScreen?.tableName || "", + position: finalPosition, + size: calculatedSize, + title: templateComp.label, + columns: [], // 초기에는 빈 배열, 나중에 설정 + filters: [], // 초기에는 빈 배열, 나중에 설정 + pagination: { + enabled: true, + pageSize: 10, + pageSizeOptions: [5, 10, 20, 50], + showPageSizeSelector: true, + showPageInfo: true, + showFirstLast: true, + }, + showSearchButton: true, + searchButtonText: "검색", + enableExport: true, + enableRefresh: true, + gridColumns, + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "600", + labelMarginBottom: "8px", + ...templateComp.style, + }, + } as ComponentData; + } else { + // 위젯 컴포넌트 + const widgetType = templateComp.widgetType || "text"; + + // 웹타입별 기본 설정 생성 + const getDefaultWebTypeConfig = (wType: string) => { + switch (wType) { + case "date": + return { + format: "YYYY-MM-DD" as const, + showTime: false, + placeholder: templateComp.placeholder || "날짜를 선택하세요", + }; + case "select": + case "dropdown": + return { + options: [ + { label: "옵션 1", value: "option1" }, + { label: "옵션 2", value: "option2" }, + { label: "옵션 3", value: "option3" }, + ], + multiple: false, + searchable: false, + placeholder: templateComp.placeholder || "옵션을 선택하세요", + }; + case "text": + return { + format: "none" as const, + placeholder: templateComp.placeholder || "텍스트를 입력하세요", + multiline: false, + }; + case "email": + return { + format: "email" as const, + placeholder: templateComp.placeholder || "이메일을 입력하세요", + multiline: false, + }; + case "tel": + return { + format: "phone" as const, + placeholder: templateComp.placeholder || "전화번호를 입력하세요", + multiline: false, + }; + case "textarea": + return { + rows: 3, + placeholder: templateComp.placeholder || "텍스트를 입력하세요", + resizable: true, + wordWrap: true, + }; + default: + return { + placeholder: templateComp.placeholder || "입력하세요", + }; + } + }; + + return { + id: componentId, + type: "widget", + widgetType: widgetType as any, + label: templateComp.label, + placeholder: templateComp.placeholder, + columnName: `field_${index + 1}`, + position: finalPosition, + size: templateComp.size, + required: templateComp.required || false, + readonly: templateComp.readonly || false, + gridColumns: 1, + webTypeConfig: getDefaultWebTypeConfig(widgetType), + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "600", + labelMarginBottom: "8px", + ...templateComp.style, + }, + } as ComponentData; + } + }); + + // 레이아웃에 새 컴포넌트들 추가 + const newLayout = { + ...layout, + components: [...layout.components, ...newComponents], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + + // 첫 번째 컴포넌트 선택 + if (newComponents.length > 0) { + setSelectedComponent(newComponents[0]); + openPanel("properties"); + } + + toast.success(`${template.name} 템플릿이 추가되었습니다.`); + }, + [layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel], + ); + // 드래그 앤 드롭 처리 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -587,7 +849,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD if (!dragData) return; try { - const { type, table, column } = JSON.parse(dragData); + const parsedData = JSON.parse(dragData); + + // 템플릿 드래그인 경우 + if (parsedData.type === "template") { + handleTemplateDrop(e, parsedData.template); + return; + } + + // 기존 테이블/컬럼 드래그 처리 + const { type, table, column } = parsedData; const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; @@ -2081,8 +2352,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD

캔버스가 비어있습니다

-

좌측 테이블 패널에서 테이블이나 컬럼을 드래그하여 화면을 설계하세요

-

단축키: T(테이블), P(속성), S(스타일), R(격자), D(상세설정)

+

좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요

+

단축키: T(테이블), M(템플릿), P(속성), S(스타일), R(격자), D(상세설정)

편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제)

@@ -2121,6 +2392,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD /> + closePanel("templates")} + position="left" + width={380} + height={700} + autoHeight={false} + > + { + const dragData = { + type: "template", + template, + }; + e.dataTransfer.setData("application/json", JSON.stringify(dragData)); + }} + /> + + { + console.log("🔧 속성 업데이트 요청:", { + componentId: selectedComponent?.id, + componentType: selectedComponent?.type, + path, + value: typeof value === "object" ? JSON.stringify(value).substring(0, 100) + "..." : value, + }); if (selectedComponent) { updateComponentProperty(selectedComponent.id, path, value); } diff --git a/frontend/components/screen/panels/DataTableConfigPanel.tsx b/frontend/components/screen/panels/DataTableConfigPanel.tsx new file mode 100644 index 00000000..27358289 --- /dev/null +++ b/frontend/components/screen/panels/DataTableConfigPanel.tsx @@ -0,0 +1,1138 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Table, + Plus, + Trash2, + Settings, + Filter, + Columns, + Eye, + EyeOff, + ArrowUpDown, + Search, + Grid3x3, +} from "lucide-react"; +import { DataTableComponent, DataTableColumn, DataTableFilter, TableInfo, ColumnInfo, WebType } from "@/types/screen"; +import { generateComponentId } from "@/lib/utils/generateId"; + +interface DataTableConfigPanelProps { + component: DataTableComponent; + tables: TableInfo[]; + onUpdateComponent: (updates: Partial) => void; +} + +const webTypeOptions: { value: WebType; label: string }[] = [ + { value: "text", label: "텍스트" }, + { value: "number", label: "숫자" }, + { value: "decimal", label: "소수" }, + { value: "date", label: "날짜" }, + { value: "datetime", label: "날짜시간" }, + { value: "select", label: "선택박스" }, + { value: "checkbox", label: "체크박스" }, + { value: "email", label: "이메일" }, + { value: "tel", label: "전화번호" }, +]; + +export const DataTableConfigPanel: React.FC = ({ component, tables, onUpdateComponent }) => { + const [selectedTable, setSelectedTable] = useState(null); + + // 로컬 입력 상태 (실시간 타이핑용) + const [localValues, setLocalValues] = useState({ + title: component.title || "", + searchButtonText: component.searchButtonText || "검색", + showSearchButton: component.showSearchButton ?? true, + enableExport: component.enableExport ?? true, + enableRefresh: component.enableRefresh ?? true, + paginationEnabled: component.pagination?.enabled ?? true, + showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true, + showPageInfo: component.pagination?.showPageInfo ?? true, + showFirstLast: component.pagination?.showFirstLast ?? true, + gridColumns: component.gridColumns || 6, + }); + + // 컬럼별 로컬 입력 상태 + const [localColumnInputs, setLocalColumnInputs] = useState>({}); + + // 컬럼별 체크박스 및 설정 상태 + const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState< + Record< + string, + { + visible: boolean; + sortable: boolean; + searchable: boolean; + } + > + >({}); + + // 컬럼별 그리드 컬럼 설정 상태 + const [localColumnGridColumns, setLocalColumnGridColumns] = useState>({}); + + // 컴포넌트 변경 시 로컬 값 동기화 + useEffect(() => { + console.log("🔄 DataTableConfig: 컴포넌트 변경 감지", { + componentId: component.id, + title: component.title, + searchButtonText: component.searchButtonText, + columnsCount: component.columns.length, + filtersCount: component.filters.length, + columnIds: component.columns.map((col) => col.id), + filterColumnNames: component.filters.map((filter) => filter.columnName), + timestamp: new Date().toISOString(), + }); + + // 컬럼과 필터 상세 정보 로그 + if (component.columns.length > 0) { + console.log( + "📋 현재 컬럼 목록:", + component.columns.map((col) => ({ + id: col.id, + columnName: col.columnName, + label: col.label, + visible: col.visible, + gridColumns: col.gridColumns, + })), + ); + } + + // 로컬 상태 정보 로그 + console.log("🔧 로컬 상태 정보:", { + localColumnInputsCount: Object.keys(localColumnInputs).length, + localColumnCheckboxesCount: Object.keys(localColumnCheckboxes).length, + localColumnGridColumnsCount: Object.keys(localColumnGridColumns).length, + }); + + if (component.filters.length > 0) { + console.log( + "🔍 현재 필터 목록:", + component.filters.map((filter) => ({ + columnName: filter.columnName, + widgetType: filter.widgetType, + label: filter.label, + })), + ); + } + + setLocalValues({ + title: component.title || "", + searchButtonText: component.searchButtonText || "검색", + showSearchButton: component.showSearchButton ?? true, + enableExport: component.enableExport ?? true, + enableRefresh: component.enableRefresh ?? true, + paginationEnabled: component.pagination?.enabled ?? true, + showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true, + showPageInfo: component.pagination?.showPageInfo ?? true, + showFirstLast: component.pagination?.showFirstLast ?? true, + gridColumns: component.gridColumns || 6, + }); + + // 컬럼 라벨 로컬 상태 초기화 (기존 값이 없는 경우만) + setLocalColumnInputs((prev) => { + const newInputs = { ...prev }; + component.columns.forEach((col) => { + // 기존에 로컬 입력값이 없는 경우만 초기화 + if (!(col.id in newInputs)) { + newInputs[col.id] = col.label; + } + }); + + // 삭제된 컬럼의 로컬 상태 제거 + const currentColumnIds = new Set(component.columns.map((col) => col.id)); + Object.keys(newInputs).forEach((id) => { + if (!currentColumnIds.has(id)) { + delete newInputs[id]; + } + }); + + return newInputs; + }); + + // 컬럼별 체크박스 상태 초기화 + setLocalColumnCheckboxes((prev) => { + const newCheckboxes = { ...prev }; + component.columns.forEach((col) => { + // 기존에 로컬 체크박스 상태가 없는 경우만 초기화 + if (!(col.id in newCheckboxes)) { + newCheckboxes[col.id] = { + visible: col.visible, + sortable: col.sortable, + searchable: col.searchable, + }; + } + }); + + // 삭제된 컬럼의 로컬 상태 제거 + const currentColumnIds = new Set(component.columns.map((col) => col.id)); + Object.keys(newCheckboxes).forEach((id) => { + if (!currentColumnIds.has(id)) { + delete newCheckboxes[id]; + } + }); + + return newCheckboxes; + }); + + // 컬럼별 그리드 컬럼 설정 상태 초기화 + setLocalColumnGridColumns((prev) => { + const newGridColumns = { ...prev }; + component.columns.forEach((col) => { + // 기존에 로컬 그리드 컬럼 설정이 없는 경우만 초기화 + if (!(col.id in newGridColumns)) { + newGridColumns[col.id] = col.gridColumns; + } + }); + + // 삭제된 컬럼의 로컬 상태 제거 + const currentColumnIds = new Set(component.columns.map((col) => col.id)); + Object.keys(newGridColumns).forEach((id) => { + if (!currentColumnIds.has(id)) { + delete newGridColumns[id]; + } + }); + + return newGridColumns; + }); + }, [ + component.id, + component.title, + component.searchButtonText, + component.columns, + component.filters, + component.showSearchButton, + component.enableExport, + component.enableRefresh, + component.pagination, + component.columns.length, // 컬럼 개수 변경 감지 + component.filters.length, // 필터 개수 변경 감지 + ]); + + // 선택된 테이블 정보 로드 + useEffect(() => { + if (component.tableName && tables.length > 0) { + const table = tables.find((t) => t.tableName === component.tableName); + setSelectedTable(table || null); + } + }, [component.tableName, tables]); + + // 테이블 변경 시 컬럼 자동 설정 + const handleTableChange = useCallback( + (tableName: string) => { + const table = tables.find((t) => t.tableName === tableName); + if (!table) return; + + console.log("🔄 테이블 변경:", { + tableName, + table, + columnsCount: table.columns.length, + columns: table.columns.map((col) => ({ + name: col.columnName, + label: col.columnLabel, + type: col.dataType, + })), + }); + + // 테이블의 모든 컬럼을 기본 설정으로 추가 + const defaultColumns: DataTableColumn[] = table.columns.map((col, index) => ({ + id: generateComponentId(), + columnName: col.columnName, + label: col.columnLabel || col.columnName, + widgetType: getWidgetTypeFromColumn(col), + gridColumns: 2, // 기본 2칸 + visible: index < 6, // 처음 6개만 기본으로 표시 + filterable: isFilterableWebType(getWidgetTypeFromColumn(col)), + sortable: true, + searchable: ["text", "email", "tel"].includes(getWidgetTypeFromColumn(col)), + })); + + // 필터는 사용자가 수동으로 추가 + + console.log("✅ 생성된 컬럼 설정:", { + defaultColumnsCount: defaultColumns.length, + visibleColumns: defaultColumns.filter((col) => col.visible).length, + }); + + onUpdateComponent({ + tableName, + columns: defaultColumns, + filters: [], // 빈 필터 배열 + }); + + setSelectedTable(table); + }, + [tables, onUpdateComponent], + ); + + // 컬럼 타입 추론 + const getWidgetTypeFromColumn = (column: ColumnInfo): WebType => { + const type = column.dataType?.toLowerCase() || ""; + const name = column.columnName.toLowerCase(); + + console.log("🔍 웹타입 추론:", { + columnName: column.columnName, + dataType: column.dataType, + type, + name, + }); + + // 숫자 타입 + if (type.includes("int") || type.includes("integer") || type.includes("bigint") || type.includes("smallint")) { + return "number"; + } + if ( + type.includes("decimal") || + type.includes("numeric") || + type.includes("float") || + type.includes("double") || + type.includes("real") + ) { + return "decimal"; + } + + // 날짜/시간 타입 + if (type.includes("timestamp") || type.includes("datetime")) { + return "datetime"; + } + if (type.includes("date")) { + return "date"; + } + if (type.includes("time")) { + return "datetime"; + } + + // 불린 타입 + if (type.includes("bool") || type.includes("boolean")) { + return "checkbox"; + } + + // 컬럼명 기반 추론 + if (name.includes("email") || name.includes("mail")) return "email"; + if (name.includes("phone") || name.includes("tel") || name.includes("mobile")) return "tel"; + if (name.includes("url") || name.includes("link")) return "text"; + if (name.includes("password") || name.includes("pwd")) return "text"; + + // 텍스트 타입 (기본값) + return "text"; + }; + + // 컬럼 업데이트 + const updateColumn = useCallback( + (columnId: string, updates: Partial) => { + const updatedColumns = component.columns.map((col) => (col.id === columnId ? { ...col, ...updates } : col)); + onUpdateComponent({ columns: updatedColumns }); + }, + [component.columns, onUpdateComponent], + ); + + // 컬럼 삭제 + const removeColumn = useCallback( + (columnId: string) => { + const columnToRemove = component.columns.find((col) => col.id === columnId); + const updatedColumns = component.columns.filter((col) => col.id !== columnId); + + // 로컬 상태에서도 해당 컬럼 제거 + setLocalColumnInputs((prev) => { + const newInputs = { ...prev }; + delete newInputs[columnId]; + return newInputs; + }); + + // 로컬 체크박스 상태에서도 해당 컬럼 제거 + setLocalColumnCheckboxes((prev) => { + const newCheckboxes = { ...prev }; + delete newCheckboxes[columnId]; + return newCheckboxes; + }); + + // 로컬 그리드 컬럼 상태에서도 해당 컬럼 제거 + setLocalColumnGridColumns((prev) => { + const newGridColumns = { ...prev }; + delete newGridColumns[columnId]; + return newGridColumns; + }); + + console.log("🗑️ 컬럼 삭제:", { + columnId, + columnName: columnToRemove?.columnName, + remainingColumns: updatedColumns.length, + }); + + onUpdateComponent({ + columns: updatedColumns, + }); + }, + [component.columns, onUpdateComponent], + ); + + // 필터 업데이트 + const updateFilter = useCallback( + (index: number, updates: Partial) => { + const updatedFilters = component.filters.map((filter, i) => (i === index ? { ...filter, ...updates } : filter)); + onUpdateComponent({ filters: updatedFilters }); + }, + [component.filters, onUpdateComponent], + ); + + // 필터 추가 + const addFilter = useCallback(() => { + if (!selectedTable) return; + + // 필터 가능한 컬럼들 중에서 아직 필터가 없는 컬럼들만 선택 + const availableColumns = selectedTable.columns.filter( + (col) => + isFilterableWebType(getWidgetTypeFromColumn(col)) && + !component.filters.some((filter) => filter.columnName === col.columnName), + ); + + if (availableColumns.length === 0) return; + + const targetColumn = availableColumns[0]; + const widgetType = getWidgetTypeFromColumn(targetColumn); + + const newFilter: DataTableFilter = { + columnName: targetColumn.columnName, + widgetType, + label: `${targetColumn.columnLabel || targetColumn.columnName} 필터`, + gridColumns: 3, + }; + + console.log("➕ 필터 추가 시작:", { + targetColumnName: targetColumn.columnName, + targetColumnLabel: targetColumn.columnLabel, + inferredWidgetType: widgetType, + currentFiltersCount: component.filters.length, + }); + + console.log("➕ 생성된 새 필터:", { + columnName: newFilter.columnName, + widgetType: newFilter.widgetType, + label: newFilter.label, + gridColumns: newFilter.gridColumns, + }); + + const updatedFilters = [...component.filters, newFilter]; + console.log("🔄 필터 업데이트 호출:", { + filtersToAdd: 1, + totalFiltersAfter: updatedFilters.length, + updatedFilters: updatedFilters.map((filter) => ({ + columnName: filter.columnName, + widgetType: filter.widgetType, + label: filter.label, + })), + }); + + onUpdateComponent({ filters: updatedFilters }); + + console.log("✅ 필터 추가 완료 - onUpdateComponent 호출됨"); + }, [selectedTable, component.filters, onUpdateComponent]); + + // 필터 삭제 + const removeFilter = useCallback( + (index: number) => { + const updatedFilters = component.filters.filter((_, i) => i !== index); + onUpdateComponent({ filters: updatedFilters }); + }, + [component.filters, onUpdateComponent], + ); + + // 웹 타입별 필터 가능 여부 확인 + const isFilterableWebType = (webType: WebType): boolean => { + const filterableTypes: WebType[] = ["text", "number", "decimal", "date", "datetime", "select", "email", "tel"]; + return filterableTypes.includes(webType); + }; + + // 컬럼 추가 (테이블에서 선택) + const addColumn = useCallback( + (columnName?: string) => { + if (!selectedTable) return; + + const availableColumns = selectedTable.columns.filter( + (col) => !component.columns.some((column) => column.columnName === col.columnName), + ); + + if (availableColumns.length === 0) return; + + // 특정 컬럼이 지정되었으면 해당 컬럼을, 아니면 첫 번째 사용 가능한 컬럼을 사용 + const targetColumn = columnName + ? availableColumns.find((col) => col.columnName === columnName) || availableColumns[0] + : availableColumns[0]; + + const widgetType = getWidgetTypeFromColumn(targetColumn); + + const newColumn: DataTableColumn = { + id: generateComponentId(), + columnName: targetColumn.columnName, + label: targetColumn.columnLabel || targetColumn.columnName, + widgetType, + gridColumns: 2, + visible: true, + filterable: isFilterableWebType(widgetType), + sortable: true, + searchable: ["text", "email", "tel"].includes(widgetType), + }; + + // 필터는 자동으로 추가하지 않음 (사용자가 수동으로 추가) + + console.log("➕ 컬럼 추가 시작:", { + targetColumnName: targetColumn.columnName, + targetColumnLabel: targetColumn.columnLabel, + inferredWidgetType: widgetType, + currentColumnsCount: component.columns.length, + currentFiltersCount: component.filters.length, + }); + + console.log("➕ 생성된 새 컬럼:", { + id: newColumn.id, + columnName: newColumn.columnName, + label: newColumn.label, + widgetType: newColumn.widgetType, + filterable: newColumn.filterable, + visible: newColumn.visible, + sortable: newColumn.sortable, + searchable: newColumn.searchable, + }); + + // 필터는 수동으로만 추가 + + // 로컬 상태에 새 컬럼 입력값 추가 + setLocalColumnInputs((prev) => { + const newInputs = { + ...prev, + [newColumn.id]: newColumn.label, + }; + console.log("🔄 로컬 컬럼 상태 업데이트:", { + newColumnId: newColumn.id, + newLabel: newColumn.label, + totalLocalInputs: Object.keys(newInputs).length, + }); + return newInputs; + }); + + // 로컬 체크박스 상태에 새 컬럼 추가 + setLocalColumnCheckboxes((prev) => ({ + ...prev, + [newColumn.id]: { + visible: newColumn.visible, + sortable: newColumn.sortable, + searchable: newColumn.searchable, + }, + })); + + // 로컬 그리드 컬럼 상태에 새 컬럼 추가 + setLocalColumnGridColumns((prev) => ({ + ...prev, + [newColumn.id]: newColumn.gridColumns, + })); + + // 컬럼만 업데이트 + const updates: Partial = { + columns: [...component.columns, newColumn], + }; + + console.log("🔄 컴포넌트 업데이트 호출:", { + columnsToAdd: 1, + totalColumnsAfter: updates.columns?.length, + hasColumns: !!updates.columns, + updateKeys: Object.keys(updates), + }); + + console.log("🔄 업데이트 상세 내용:", { + columns: updates.columns?.map((col) => ({ id: col.id, columnName: col.columnName, label: col.label })), + }); + + onUpdateComponent(updates); + + console.log("✅ 컬럼 추가 완료 - onUpdateComponent 호출됨"); + }, + [selectedTable, component.columns, component.filters, onUpdateComponent], + ); + + return ( +
+ {/* 기본 설정 */} + + + + + 기본 설정 + + + +
+ + +
+ +
+ + { + const newValue = e.target.value; + setLocalValues((prev) => ({ ...prev, title: newValue })); + onUpdateComponent({ title: newValue }); + }} + placeholder="테이블 제목을 입력하세요" + /> +
+ +
+ + +
+ +
+
+ { + console.log("🔄 검색 버튼 표시 변경:", checked); + setLocalValues((prev) => ({ ...prev, showSearchButton: checked as boolean })); + onUpdateComponent({ showSearchButton: checked as boolean }); + }} + /> + +
+ +
+ { + console.log("🔄 내보내기 기능 변경:", checked); + setLocalValues((prev) => ({ ...prev, enableExport: checked as boolean })); + onUpdateComponent({ enableExport: checked as boolean }); + }} + /> + +
+
+
+
+ + {/* 탭 설정 */} + + + + + 컬럼 + + + + 필터 + + + + 페이징 + + + + {/* 컬럼 설정 */} + +
+

테이블 컬럼 설정

+
+ {component.columns.length}개 + {selectedTable && + (() => { + const availableColumns = selectedTable.columns.filter( + (col) => !component.columns.some((column) => column.columnName === col.columnName), + ); + + return availableColumns.length > 0 ? ( + + ) : ( + + ); + })()} +
+
+ +
+ {component.columns.map((column, index) => ( + +
+
+
+ { + console.log("🔄 컬럼 표시 변경:", { columnId: column.id, checked }); + setLocalColumnCheckboxes((prev) => ({ + ...prev, + [column.id]: { ...prev[column.id], visible: checked as boolean }, + })); + updateColumn(column.id, { visible: checked as boolean }); + }} + /> + {column.label} + + {column.columnName} + +
+ +
+ +
+ + { + const newValue = e.target.value; + setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue })); + updateColumn(column.id, { label: newValue }); + }} + onBlur={(e) => { + // 포커스 잃을 때 빈 값이면 원본 라벨로 복원하지 않음 (사용자가 의도적으로 지운 것) + const newValue = e.target.value; + if (newValue !== localColumnInputs[column.id]) { + setLocalColumnInputs((prev) => ({ ...prev, [column.id]: newValue })); + updateColumn(column.id, { label: newValue }); + } + }} + placeholder="표시명을 입력하세요" + className="h-8 text-xs" + /> +
+ +
+
+ + +
+ +
+ { + console.log("🔄 컬럼 정렬 가능 변경:", { columnId: column.id, checked }); + setLocalColumnCheckboxes((prev) => ({ + ...prev, + [column.id]: { ...prev[column.id], sortable: checked as boolean }, + })); + updateColumn(column.id, { sortable: checked as boolean }); + }} + /> + +
+ +
+ { + console.log("🔄 컬럼 검색 가능 변경:", { columnId: column.id, checked }); + setLocalColumnCheckboxes((prev) => ({ + ...prev, + [column.id]: { ...prev[column.id], searchable: checked as boolean }, + })); + updateColumn(column.id, { searchable: checked as boolean }); + }} + /> + +
+
+
+
+ ))} +
+
+ + {/* 필터 설정 */} + +
+

검색 필터 설정

+
+ {component.filters.length}개 + +
+
+ + {component.filters.length === 0 ? ( +
+ +

필터가 없습니다

+

컬럼을 추가하면 자동으로 필터가 생성됩니다

+
+ ) : ( +
+ {component.filters.map((filter, index) => { + const getWebTypeIcon = (webType: WebType) => { + switch (webType) { + case "text": + case "email": + case "tel": + return "📝"; + case "number": + case "decimal": + return "🔢"; + case "date": + case "datetime": + return "📅"; + case "select": + return "📋"; + default: + return "🔍"; + } + }; + + const getWebTypeDescription = (webType: WebType) => { + switch (webType) { + case "text": + return "텍스트 검색 (부분 일치)"; + case "email": + return "이메일 형식 검색"; + case "tel": + return "전화번호 검색"; + case "number": + return "숫자 범위 검색"; + case "decimal": + return "소수점 범위 검색"; + case "date": + return "날짜 범위 검색"; + case "datetime": + return "날짜시간 범위 검색"; + case "select": + return "선택 옵션 필터"; + default: + return "기본 검색"; + } + }; + + return ( + +
+
+
+ {getWebTypeIcon(filter.widgetType)} +
+ {filter.label} +

{getWebTypeDescription(filter.widgetType)}

+
+
+ +
+ +
+
+ + +
+ +
+ +
+ + {webTypeOptions.find((opt) => opt.value === filter.widgetType)?.label || + filter.widgetType} + +
+
+ +
+ + +
+
+ + {/* 웹 타입별 추가 설정 미리보기 */} +
+
+ {filter.widgetType === "date" || filter.widgetType === "datetime" ? ( + 📅 날짜 범위 선택 (시작일 ~ 종료일) + ) : filter.widgetType === "number" || filter.widgetType === "decimal" ? ( + 🔢 숫자 범위 입력 (최소값 ~ 최대값) + ) : filter.widgetType === "select" ? ( + 📋 다중 선택 옵션 + ) : ( + 🔍 텍스트 입력 검색 + )} +
+
+
+
+ ); + })} +
+ )} +
+ + {/* 페이지네이션 설정 */} + +
+
+ { + console.log("🔄 페이지네이션 사용 변경:", checked); + setLocalValues((prev) => ({ ...prev, paginationEnabled: checked as boolean })); + onUpdateComponent({ + pagination: { ...component.pagination, enabled: checked as boolean }, + }); + }} + /> + +
+ + {component.pagination.enabled && ( +
+
+
+ + +
+
+ +
+
+ { + console.log("🔄 페이지 크기 선택기 표시 변경:", checked); + setLocalValues((prev) => ({ ...prev, showPageSizeSelector: checked as boolean })); + onUpdateComponent({ + pagination: { + ...component.pagination, + showPageSizeSelector: checked as boolean, + }, + }); + }} + /> + +
+ +
+ { + console.log("🔄 페이지 정보 표시 변경:", checked); + setLocalValues((prev) => ({ ...prev, showPageInfo: checked as boolean })); + onUpdateComponent({ + pagination: { + ...component.pagination, + showPageInfo: checked as boolean, + }, + }); + }} + /> + +
+ +
+ { + console.log("🔄 처음/마지막 버튼 표시 변경:", checked); + setLocalValues((prev) => ({ ...prev, showFirstLast: checked as boolean })); + onUpdateComponent({ + pagination: { + ...component.pagination, + showFirstLast: checked as boolean, + }, + }); + }} + /> + +
+
+
+ )} +
+
+ + + ); +}; + +export default DataTableConfigPanel; diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index 38c3ae8f..06a0beb2 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -9,10 +9,12 @@ import { Separator } from "@/components/ui/separator"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react"; -import { ComponentData, WebType, WidgetComponent, GroupComponent } from "@/types/screen"; +import { ComponentData, WebType, WidgetComponent, GroupComponent, DataTableComponent, TableInfo } from "@/types/screen"; +import DataTableConfigPanel from "./DataTableConfigPanel"; interface PropertiesPanelProps { selectedComponent?: ComponentData; + tables?: TableInfo[]; onUpdateProperty: (path: string, value: unknown) => void; onDeleteComponent: () => void; onCopyComponent: () => void; @@ -43,6 +45,7 @@ const webTypeOptions: { value: WebType; label: string }[] = [ export const PropertiesPanel: React.FC = ({ selectedComponent, + tables = [], onUpdateProperty, onDeleteComponent, onCopyComponent, @@ -71,6 +74,7 @@ export const PropertiesPanel: React.FC = ({ labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px", required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false, readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false, + labelDisplay: selectedComponent?.style?.labelDisplay !== false, }); useEffect(() => { @@ -83,6 +87,18 @@ export const PropertiesPanel: React.FC = ({ if (selectedComponent) { const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null; const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null; + + console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", { + componentId: selectedComponent.id, + componentType: selectedComponent.type, + currentValues: { + placeholder: widget?.placeholder, + title: group?.title, + positionX: selectedComponent.position.x, + labelText: selectedComponent.style?.labelText || selectedComponent.label, + }, + }); + setLocalInputs({ placeholder: widget?.placeholder || "", title: group?.title || "", @@ -98,9 +114,16 @@ export const PropertiesPanel: React.FC = ({ labelMarginBottom: selectedComponent.style?.labelMarginBottom || "4px", required: widget?.required || false, readonly: widget?.readonly || false, + labelDisplay: selectedComponent.style?.labelDisplay !== false, }); } - }, [selectedComponent]); + }, [ + selectedComponent, + selectedComponent?.position, + selectedComponent?.size, + selectedComponent?.style, + selectedComponent?.label, + ]); if (!selectedComponent) { return ( @@ -112,6 +135,65 @@ export const PropertiesPanel: React.FC = ({ ); } + // 데이터 테이블 컴포넌트인 경우 전용 패널 사용 + if (selectedComponent.type === "datatable") { + return ( +
+ {/* 헤더 */} +
+
+
+ + 데이터 테이블 설정 +
+ + {selectedComponent.type} + +
+ + {/* 액션 버튼들 */} +
+ + +
+
+ + {/* 데이터 테이블 설정 패널 */} +
+ c.id))}-${JSON.stringify(selectedComponent.filters.map((f) => f.columnName))}`} + component={selectedComponent as DataTableComponent} + tables={tables} + onUpdateComponent={(updates) => { + console.log("🔄 DataTable 컴포넌트 업데이트:", updates); + console.log("🔄 업데이트 항목들:", Object.keys(updates)); + + // 각 속성을 개별적으로 업데이트 + Object.entries(updates).forEach(([key, value]) => { + console.log(` - ${key}:`, value); + if (key === "columns") { + console.log(` 컬럼 개수: ${Array.isArray(value) ? value.length : 0}`); + } + if (key === "filters") { + console.log(` 필터 개수: ${Array.isArray(value) ? value.length : 0}`); + } + onUpdateProperty(key, value); + }); + + console.log("✅ DataTable 컴포넌트 업데이트 완료"); + }} + /> +
+
+ ); + } + return (
{/* 헤더 */} @@ -210,6 +292,7 @@ export const PropertiesPanel: React.FC = ({ value={localInputs.placeholder} onChange={(e) => { const newValue = e.target.value; + console.log("🔄 placeholder 변경:", newValue); setLocalInputs((prev) => ({ ...prev, placeholder: newValue })); onUpdateProperty("placeholder", newValue); }} @@ -394,8 +477,12 @@ export const PropertiesPanel: React.FC = ({ onUpdateProperty("style.labelDisplay", checked)} + checked={localInputs.labelDisplay} + onCheckedChange={(checked) => { + console.log("🔄 라벨 표시 변경:", checked); + setLocalInputs((prev) => ({ ...prev, labelDisplay: checked as boolean })); + onUpdateProperty("style.labelDisplay", checked); + }} />
@@ -409,6 +496,7 @@ export const PropertiesPanel: React.FC = ({ value={localInputs.labelText} onChange={(e) => { const newValue = e.target.value; + console.log("🔄 라벨 텍스트 변경:", newValue); setLocalInputs((prev) => ({ ...prev, labelText: newValue })); // 기본 라벨과 스타일 라벨을 모두 업데이트 onUpdateProperty("label", newValue); diff --git a/frontend/components/screen/panels/TemplatesPanel.tsx b/frontend/components/screen/panels/TemplatesPanel.tsx new file mode 100644 index 00000000..3413af42 --- /dev/null +++ b/frontend/components/screen/panels/TemplatesPanel.tsx @@ -0,0 +1,170 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Table, Search, FileText, Grid3x3, Info } from "lucide-react"; + +// 템플릿 컴포넌트 타입 정의 +export interface TemplateComponent { + id: string; + name: string; + description: string; + category: "table" | "button" | "form" | "layout" | "chart" | "status"; + icon: React.ReactNode; + defaultSize: { width: number; height: number }; + components: Array<{ + type: "widget" | "container"; + widgetType?: string; + label: string; + placeholder?: string; + position: { x: number; y: number }; + size: { width: number; height: number }; + style?: any; + required?: boolean; + readonly?: boolean; + }>; +} + +// 미리 정의된 템플릿 컴포넌트들 +const templateComponents: TemplateComponent[] = [ + // 고급 데이터 테이블 템플릿 + { + id: "advanced-data-table", + name: "고급 데이터 테이블", + description: "컬럼 설정, 필터링, 페이지네이션이 포함된 완전한 데이터 테이블", + category: "table", + icon:
, + defaultSize: { width: 1000, height: 680 }, + components: [ + // 데이터 테이블 컴포넌트 (특별한 타입) + { + type: "datatable", + label: "데이터 테이블", + position: { x: 0, y: 0 }, + size: { width: 1000, height: 680 }, + style: { + border: "1px solid #e5e7eb", + borderRadius: "8px", + backgroundColor: "#ffffff", + padding: "16px", + }, + }, + ], + }, +]; + +interface TemplatesPanelProps { + onDragStart: (e: React.DragEvent, template: TemplateComponent) => void; +} + +export const TemplatesPanel: React.FC = ({ onDragStart }) => { + const [searchTerm, setSearchTerm] = React.useState(""); + const [selectedCategory, setSelectedCategory] = React.useState("all"); + + const categories = [ + { id: "all", name: "전체", icon: }, + { id: "table", name: "테이블", icon:
}, + ]; + + const filteredTemplates = templateComponents.filter((template) => { + const matchesSearch = + template.name.toLowerCase().includes(searchTerm.toLowerCase()) || + template.description.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesCategory = selectedCategory === "all" || template.category === selectedCategory; + return matchesSearch && matchesCategory; + }); + + return ( +
+ {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* 카테고리 필터 */} +
+ {categories.map((category) => ( + + ))} +
+
+ + + + {/* 템플릿 목록 */} +
+ {filteredTemplates.length === 0 ? ( +
+
+ +

검색 결과가 없습니다

+
+
+ ) : ( + filteredTemplates.map((template) => ( +
onDragStart(e, template)} + className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md" + > +
+
+ {template.icon} +
+
+
+

{template.name}

+ + {template.components.length}개 + +
+

{template.description}

+
+ + {template.defaultSize.width}×{template.defaultSize.height} + + + {template.category} +
+
+
+
+ )) + )} +
+ + {/* 도움말 */} +
+
+ +
+

사용 방법

+

템플릿을 캔버스로 드래그하여 빠르게 화면을 구성하세요.

+
+
+
+
+ ); +}; + +export default TemplatesPanel; diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index dc4bbb3f..d8bd4203 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -145,6 +145,35 @@ export const tableTypeApi = { detailSettings, }); }, + + // 테이블 데이터 조회 (페이지네이션 + 검색) + getTableData: async ( + tableName: string, + params: { + page?: number; + size?: number; + search?: Record; // 검색 조건 + sortBy?: string; + sortOrder?: "asc" | "desc"; + } = {}, + ): Promise<{ + data: Record[]; + total: number; + page: number; + size: number; + totalPages: number; + }> => { + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params); + const raw = response.data?.data || response.data; + + return { + data: raw.data || [], + total: raw.total || 0, + page: raw.page || params.page || 1, + size: raw.size || params.size || 10, + totalPages: raw.totalPages || Math.ceil((raw.total || 0) / (params.size || 10)), + }; + }, }; // 메뉴-화면 할당 관련 API diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts index 3b98c090..808fb534 100644 --- a/frontend/types/screen.ts +++ b/frontend/types/screen.ts @@ -1,7 +1,7 @@ // 화면관리 시스템 타입 정의 // 기본 컴포넌트 타입 -export type ComponentType = "container" | "row" | "column" | "widget" | "group"; +export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable"; // 웹 타입 정의 export type WebType = @@ -199,8 +199,62 @@ export interface WidgetComponent extends BaseComponent { webTypeConfig?: WebTypeConfig; // 웹타입별 상세 설정 } +// 데이터 테이블 컬럼 설정 +export interface DataTableColumn { + id: string; + columnName: string; // 실제 DB 컬럼명 + label: string; // 화면에 표시될 라벨 + widgetType: WebType; // 컬럼의 데이터 타입 + gridColumns: number; // 그리드에서 차지할 컬럼 수 (1-12) + visible: boolean; // 테이블에 표시할지 여부 + filterable: boolean; // 필터링 가능 여부 + sortable: boolean; // 정렬 가능 여부 + searchable: boolean; // 검색 대상 여부 + webTypeConfig?: WebTypeConfig; // 컬럼별 상세 설정 +} + +// 데이터 테이블 필터 설정 +export interface DataTableFilter { + columnName: string; + widgetType: WebType; + label: string; + gridColumns: number; // 필터에서 차지할 컬럼 수 + webTypeConfig?: WebTypeConfig; +} + +// 데이터 테이블 페이지네이션 설정 +export interface DataTablePagination { + enabled: boolean; + pageSize: number; // 페이지당 행 수 + pageSizeOptions: number[]; // 선택 가능한 페이지 크기들 + showPageSizeSelector: boolean; // 페이지 크기 선택기 표시 여부 + showPageInfo: boolean; // 페이지 정보 표시 여부 + showFirstLast: boolean; // 처음/마지막 버튼 표시 여부 +} + +// 데이터 테이블 컴포넌트 +export interface DataTableComponent extends BaseComponent { + type: "datatable"; + tableName: string; // 연결된 테이블명 + title?: string; // 테이블 제목 + columns: DataTableColumn[]; // 테이블 컬럼 설정 + filters: DataTableFilter[]; // 검색 필터 설정 + pagination: DataTablePagination; // 페이지네이션 설정 + showSearchButton: boolean; // 검색 버튼 표시 여부 + searchButtonText: string; // 검색 버튼 텍스트 + enableExport: boolean; // 내보내기 기능 활성화 + enableRefresh: boolean; // 새로고침 기능 활성화 + gridColumns: number; // 테이블이 차지할 그리드 컬럼 수 +} + // 컴포넌트 유니온 타입 -export type ComponentData = ContainerComponent | GroupComponent | RowComponent | ColumnComponent | WidgetComponent; +export type ComponentData = + | ContainerComponent + | GroupComponent + | RowComponent + | ColumnComponent + | WidgetComponent + | DataTableComponent; // 레이아웃 데이터 export interface LayoutData {