테이블 템플릿 제작
This commit is contained in:
@@ -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
|
||||
// 텍스트 입력 - 즉시 반영
|
||||
<Input
|
||||
value={localInputs.title}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
// 1) 로컬 상태 즉시 업데이트 (화면 반영)
|
||||
setLocalInputs(prev => ({ ...prev, title: newValue }));
|
||||
// 2) 글로벌 상태 업데이트 (데이터 저장)
|
||||
onUpdateProperty("title", newValue);
|
||||
}}
|
||||
/>
|
||||
|
||||
// 체크박스 - 즉시 반영
|
||||
<Checkbox
|
||||
checked={localValues.showButton}
|
||||
onCheckedChange={(checked) => {
|
||||
// 1) 로컬 상태 즉시 업데이트
|
||||
setLocalValues(prev => ({ ...prev, showButton: checked as boolean }));
|
||||
// 2) 글로벌 상태 업데이트
|
||||
onUpdateProperty("showButton", checked);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 3. 동적 컴포넌트별 상태 관리
|
||||
|
||||
```typescript
|
||||
// 컬럼별 개별 상태 관리 (ID 기반)
|
||||
const [localColumnInputs, setLocalColumnInputs] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState<
|
||||
Record<string, { visible: boolean; sortable: boolean; searchable: boolean }>
|
||||
>({});
|
||||
|
||||
// 기존 값 보존하면서 새 항목만 추가
|
||||
useEffect(() => {
|
||||
setLocalColumnInputs((prev) => {
|
||||
const newInputs = { ...prev };
|
||||
component.columns?.forEach((col) => {
|
||||
if (!(col.id in newInputs)) {
|
||||
// 기존 입력값 보존
|
||||
newInputs[col.id] = col.label;
|
||||
}
|
||||
});
|
||||
return newInputs;
|
||||
});
|
||||
}, [component.columns]);
|
||||
|
||||
// 동적 입력 처리
|
||||
<Input
|
||||
value={
|
||||
localColumnInputs[column.id] !== undefined
|
||||
? localColumnInputs[column.id]
|
||||
: column.label
|
||||
}
|
||||
onChange={(e) => {
|
||||
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<void> {
|
||||
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<null> = {
|
||||
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<any> = {
|
||||
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<string, any>;
|
||||
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<any[]>(
|
||||
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<any[]>(
|
||||
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<InteractiveDataTableProps> = ({
|
||||
component,
|
||||
className = "",
|
||||
style = {},
|
||||
}) => {
|
||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 데이터 로드 함수
|
||||
const loadData = useCallback(
|
||||
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
||||
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 (
|
||||
<div className={`bg-white ${className}`} style={style}>
|
||||
{/* 헤더: 제목, 로딩 상태, 검색 버튼 */}
|
||||
<div className="border-b bg-gray-50 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{component.title || component.label}
|
||||
{loading && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
로딩중...
|
||||
</Badge>
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
{component.showSearchButton && (
|
||||
<Button size="sm" onClick={handleSearch} disabled={loading}>
|
||||
{component.searchButtonText || "검색"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 필터 */}
|
||||
{component.filters?.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="text-xs font-medium text-gray-700">검색 필터</div>
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: component.filters
|
||||
.map((filter) => `${filter.gridColumns || 3}fr`)
|
||||
.join(" "),
|
||||
}}
|
||||
>
|
||||
{component.filters.map((filter) => renderSearchFilter(filter))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 데이터 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{/* 헤더 행 */}
|
||||
<div
|
||||
className="gap-2 border-b pb-2 text-xs font-medium text-gray-700"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: component.columns
|
||||
?.filter((col) => col.visible)
|
||||
.map((col) => `${col.gridColumns || 2}fr`)
|
||||
.join(" "),
|
||||
}}
|
||||
>
|
||||
{component.columns
|
||||
?.filter((col) => col.visible)
|
||||
.map((column) => (
|
||||
<div key={column.id} className="truncate">
|
||||
{column.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{loading ? (
|
||||
<div className="py-8 text-center text-xs text-gray-500">
|
||||
데이터를 불러오는 중...
|
||||
</div>
|
||||
) : data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="gap-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: component.columns
|
||||
?.filter((col) => col.visible)
|
||||
.map((col) => `${col.gridColumns || 2}fr`)
|
||||
.join(" "),
|
||||
}}
|
||||
>
|
||||
{component.columns
|
||||
?.filter((col) => col.visible)
|
||||
.map((column) => (
|
||||
<div key={column.id} className="truncate">
|
||||
{formatCellValue(row[column.columnName], column)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center text-xs text-gray-500">
|
||||
검색 결과가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{component.pagination?.enabled && totalPages > 1 && (
|
||||
<div className="border-t bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
{component.pagination.showPageInfo && (
|
||||
<div>
|
||||
총 {total.toLocaleString()}개 중{" "}
|
||||
{((currentPage - 1) * pageSize + 1).toLocaleString()}-
|
||||
{Math.min(currentPage * pageSize, total).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<span className="px-2">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**데이터 포맷팅**
|
||||
|
||||
```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 (
|
||||
<InteractiveDataTable
|
||||
component={comp as DataTableComponent}
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 위젯 처리...
|
||||
};
|
||||
```
|
||||
|
||||
#### **사용자 경험 (UX)**
|
||||
|
||||
**1. 관리자 (화면 설계)**
|
||||
|
||||
1. 드래그앤드롭으로 데이터 테이블 컴포넌트 배치
|
||||
2. 속성 패널에서 테이블 선택 및 컬럼 설정
|
||||
3. 검색 필터 추가 및 페이지네이션 설정
|
||||
4. 실시간 미리보기로 결과 확인
|
||||
|
||||
**2. 사용자 (실제 화면)**
|
||||
|
||||
1. 메뉴를 통해 할당된 화면 접근
|
||||
2. 검색 필터로 원하는 데이터 필터링
|
||||
3. 페이지네이션으로 대용량 데이터 탐색
|
||||
4. 실시간 데이터 로딩 및 정렬
|
||||
|
||||
#### **🎨 Shadcn UI 기반 모던 디자인**
|
||||
|
||||
**핵심 컴포넌트**
|
||||
|
||||
- **Card**: 전체 테이블을 감싸는 메인 컨테이너
|
||||
- **Table**: Shadcn Table 컴포넌트로 표준화된 테이블 UI
|
||||
- **Badge**: 로딩 상태 및 필터 개수 표시
|
||||
- **Button**: 일관된 액션 버튼 디자인
|
||||
- **Separator**: 섹션 구분선
|
||||
|
||||
**디자인 특징**
|
||||
|
||||
```typescript
|
||||
// 메인 카드 레이아웃
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
{/* 아이콘 + 제목 + 액션 버튼들 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
{loading && (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩중...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Shadcn Table 사용 */}
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead className="font-semibold">{column.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{/* 로딩, 데이터, 빈 상태 처리 */}</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**시각적 개선사항**
|
||||
|
||||
- **아이콘 통합**: 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단계: 실시간 입력 처리 - 즉시 반영
|
||||
<Input
|
||||
value={localInputs.title}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
// 1) 로컬 상태 즉시 업데이트 (화면 반영)
|
||||
setLocalInputs(prev => ({ ...prev, title: newValue }));
|
||||
// 2) 글로벌 상태 업데이트 (데이터 저장)
|
||||
onUpdateProperty("title", newValue);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={localValues.showButton}
|
||||
onCheckedChange={(checked) => {
|
||||
// 1) 로컬 상태 즉시 업데이트
|
||||
setLocalValues(prev => ({ ...prev, showButton: checked as boolean }));
|
||||
// 2) 글로벌 상태 업데이트
|
||||
onUpdateProperty("showButton", checked);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 동적 컴포넌트별 상태 관리 (ID 기반)
|
||||
|
||||
```typescript
|
||||
// 컬럼별 개별 상태 관리
|
||||
const [localColumnInputs, setLocalColumnInputs] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [localColumnCheckboxes, setLocalColumnCheckboxes] = useState<
|
||||
Record<string, { visible: boolean; sortable: boolean; searchable: boolean }>
|
||||
>({});
|
||||
|
||||
// 기존 값 보존하면서 새 항목만 추가
|
||||
useEffect(() => {
|
||||
setLocalColumnInputs((prev) => {
|
||||
const newInputs = { ...prev };
|
||||
component.columns?.forEach((col) => {
|
||||
if (!(col.id in newInputs)) {
|
||||
// 기존 입력값 보존
|
||||
newInputs[col.id] = col.label;
|
||||
}
|
||||
});
|
||||
return newInputs;
|
||||
});
|
||||
}, [component.columns]);
|
||||
|
||||
// 동적 입력 처리
|
||||
<Input
|
||||
value={
|
||||
localColumnInputs[column.id] !== undefined
|
||||
? localColumnInputs[column.id]
|
||||
: column.label
|
||||
}
|
||||
onChange={(e) => {
|
||||
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. 드래그앤드롭 패턴
|
||||
|
||||
#### 다중 컴포넌트 드래그 처리
|
||||
|
||||
Reference in New Issue
Block a user