테이블 템플릿 제작
This commit is contained in:
@@ -439,3 +439,71 @@ export async function updateColumnWebType(
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
*/
|
||||
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);
|
||||
logger.info(`정렬: ${sortBy} ${sortOrder}`);
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
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<any> = {
|
||||
success: true,
|
||||
message: "테이블 데이터를 성공적으로 조회했습니다.",
|
||||
data: result,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("테이블 데이터 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "TABLE_DATA_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -514,4 +514,105 @@ export class TableManagementService {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
*/
|
||||
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;
|
||||
|
||||
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<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);
|
||||
|
||||
logger.info(`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
size,
|
||||
totalPages
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 드래그앤드롭 패턴
|
||||
|
||||
#### 다중 컴포넌트 드래그 처리
|
||||
|
||||
@@ -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<DesignerToolbarProps> = ({
|
||||
</Badge>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={panelStates.templates?.isOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onTogglePanel("templates")}
|
||||
className={cn("flex items-center space-x-2", panelStates.templates?.isOpen && "bg-blue-600 text-white")}
|
||||
>
|
||||
<Layout className="h-4 w-4" />
|
||||
<span>템플릿</span>
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
M
|
||||
</Badge>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={panelStates.properties?.isOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
|
||||
438
frontend/components/screen/InteractiveDataTable.tsx
Normal file
438
frontend/components/screen/InteractiveDataTable.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Search, ChevronLeft, ChevronRight, RotateCcw, Database, Loader2 } from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InteractiveDataTableProps {
|
||||
component: DataTableComponent;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
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 visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
||||
const searchFilters = component.filters || [];
|
||||
|
||||
// 그리드 컬럼 계산
|
||||
const totalGridColumns = visibleColumns.reduce((sum, col) => sum + (col.gridColumns || 2), 0);
|
||||
|
||||
// 페이지 크기 설정
|
||||
const pageSize = component.pagination?.pageSize || 10;
|
||||
|
||||
// 데이터 로드 함수
|
||||
const loadData = useCallback(
|
||||
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
||||
if (!component.tableName) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log("🔍 테이블 데이터 조회:", {
|
||||
tableName: component.tableName,
|
||||
page,
|
||||
pageSize,
|
||||
searchParams,
|
||||
});
|
||||
|
||||
const result = await tableTypeApi.getTableData(component.tableName, {
|
||||
page,
|
||||
size: pageSize,
|
||||
search: searchParams,
|
||||
});
|
||||
|
||||
console.log("✅ 테이블 데이터 조회 결과:", result);
|
||||
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
setTotalPages(result.totalPages);
|
||||
setCurrentPage(result.page);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[component.tableName, pageSize],
|
||||
);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData(1, searchValues);
|
||||
}, [loadData]);
|
||||
|
||||
// 검색 실행
|
||||
const handleSearch = useCallback(() => {
|
||||
console.log("🔍 검색 실행:", searchValues);
|
||||
loadData(1, searchValues);
|
||||
}, [searchValues, loadData]);
|
||||
|
||||
// 검색값 변경
|
||||
const handleSearchValueChange = useCallback((columnName: string, value: any) => {
|
||||
setSearchValues((prev) => ({
|
||||
...prev,
|
||||
[columnName]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 페이지 변경
|
||||
const handlePageChange = useCallback(
|
||||
(page: number) => {
|
||||
loadData(page, searchValues);
|
||||
},
|
||||
[loadData, searchValues],
|
||||
);
|
||||
|
||||
// 검색 필터 렌더링
|
||||
const renderSearchFilter = (filter: DataTableFilter) => {
|
||||
const value = searchValues[filter.columnName] || "";
|
||||
|
||||
switch (filter.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="number"
|
||||
placeholder={`${filter.label} 입력...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "datetime":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
// TODO: 선택 옵션은 추후 구현
|
||||
return (
|
||||
<Select
|
||||
key={filter.columnName}
|
||||
value={value}
|
||||
onValueChange={(newValue) => handleSearchValueChange(filter.columnName, newValue)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체</SelectItem>
|
||||
{/* TODO: 동적 옵션 로드 */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 셀 값 포맷팅
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}>
|
||||
{/* 헤더 */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-lg">{component.title || component.label}</CardTitle>
|
||||
{loading && (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩중...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{searchFilters.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Search className="mr-1 h-3 w-3" />
|
||||
필터 {searchFilters.length}개
|
||||
</Badge>
|
||||
)}
|
||||
{component.showSearchButton && (
|
||||
<Button size="sm" onClick={handleSearch} disabled={loading} className="gap-2">
|
||||
<Search className="h-3 w-3" />
|
||||
{component.searchButtonText || "검색"}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => loadData(1, {})} disabled={loading} className="gap-2">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 필터 */}
|
||||
{searchFilters.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Search className="h-3 w-3" />
|
||||
검색 필터
|
||||
</CardDescription>
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: searchFilters
|
||||
.map((filter: DataTableFilter) => `${filter.gridColumns || 3}fr`)
|
||||
.join(" "),
|
||||
}}
|
||||
>
|
||||
{searchFilters.map((filter: DataTableFilter) => (
|
||||
<div key={filter.columnName} className="space-y-1">
|
||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
||||
{renderSearchFilter(filter)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{/* 테이블 내용 */}
|
||||
<CardContent className="flex-1 p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
{visibleColumns.length > 0 ? (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
className="px-4 font-semibold"
|
||||
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
||||
>
|
||||
{column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
데이터를 불러오는 중...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex} className="hover:bg-muted/50">
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
||||
{formatCellValue(row[column.columnName], column)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Database className="h-8 w-8" />
|
||||
<p>검색 결과가 없습니다</p>
|
||||
<p className="text-xs">검색 조건을 변경하거나 새로고침을 시도해보세요</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{component.pagination?.enabled && totalPages > 1 && (
|
||||
<div className="bg-muted/20 mt-auto border-t">
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
{component.pagination.showPageInfo && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
총 <span className="font-medium">{total.toLocaleString()}</span>개 중{" "}
|
||||
<span className="font-medium">{((currentPage - 1) * pageSize + 1).toLocaleString()}</span>-
|
||||
<span className="font-medium">{Math.min(currentPage * pageSize, total).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
{component.pagination.showFirstLast && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="gap-1"
|
||||
>
|
||||
처음
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
이전
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 text-sm font-medium">
|
||||
<span>{currentPage}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span>{totalPages}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="gap-1"
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
{component.pagination.showFirstLast && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="gap-1"
|
||||
>
|
||||
마지막
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Database className="h-8 w-8" />
|
||||
<p className="text-sm">표시할 컬럼이 없습니다</p>
|
||||
<p className="text-xs">테이블 설정에서 컬럼을 추가해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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<InteractiveScreenViewerProps> = (
|
||||
|
||||
// 실제 사용 가능한 위젯 렌더링
|
||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||
// 데이터 테이블 컴포넌트 처리
|
||||
if (comp.type === "datatable") {
|
||||
return (
|
||||
<InteractiveDataTable
|
||||
component={comp as DataTableComponent}
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<InteractiveScreenViewerProps> = (
|
||||
}
|
||||
|
||||
// 일반 위젯 컴포넌트
|
||||
// 템플릿 컴포넌트 목록 (자체적으로 제목을 가지므로 라벨 불필요)
|
||||
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 (
|
||||
<div className="h-full w-full">
|
||||
{/* 라벨이 있는 경우 표시 */}
|
||||
{component.label && (
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
{component.label}
|
||||
{component.required && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<div className="block" style={labelStyle}>
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 */}
|
||||
<div className={component.label ? "flex-1" : "h-full w-full"}>{renderInteractiveWidget(component)}</div>
|
||||
<div className={shouldShowLabel ? "flex-1" : "h-full w-full"}>{renderInteractiveWidget(component)}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<RealtimePreviewProps> = ({
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
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<RealtimePreviewProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className="absolute cursor-move"
|
||||
style={{
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: `${size.width}px`,
|
||||
height: shouldShowLabel ? `${size.height + 20 + labelMarginBottomValue}px` : `${size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
...(isSelected ? { boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.5)" } : {}),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* 라벨 표시 */}
|
||||
{shouldShowLabel && (
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 w-full truncate"
|
||||
style={{
|
||||
...labelStyle,
|
||||
top: `${-20 - labelMarginBottomValue}px`,
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shadcn UI 기반 데이터 테이블 */}
|
||||
<Card
|
||||
className="flex h-full w-full flex-col overflow-hidden"
|
||||
style={{
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`,
|
||||
}}
|
||||
>
|
||||
{/* 카드 헤더 */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-sm">{dataTableComponent.title || label}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{filters.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Search className="mr-1 h-3 w-3" />
|
||||
필터 {filters.length}개
|
||||
</Badge>
|
||||
)}
|
||||
{dataTableComponent.showSearchButton && (
|
||||
<Button size="sm" className="gap-1 text-xs">
|
||||
<Search className="h-3 w-3" />
|
||||
{dataTableComponent.searchButtonText || "검색"}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="gap-1 text-xs">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 미리보기 */}
|
||||
{filters.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Search className="h-3 w-3" />
|
||||
검색 필터
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: filters.map((filter: any) => `${filter.gridColumns || 3}fr`).join(" "),
|
||||
}}
|
||||
>
|
||||
{filters.map((filter: any, index: number) => (
|
||||
<div key={`filter-${index}`} className="space-y-1">
|
||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
||||
<div className="bg-background text-muted-foreground rounded border px-2 py-1 text-xs">
|
||||
검색...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{/* 테이블 내용 */}
|
||||
<CardContent className="flex-1 p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
{visibleColumns.length > 0 ? (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{visibleColumns.map((column: any) => (
|
||||
<TableHead key={column.id} className="px-4 text-xs font-semibold">
|
||||
{column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* 샘플 데이터 3행 */}
|
||||
<TableRow className="hover:bg-muted/50">
|
||||
{visibleColumns.map((column: any, colIndex: number) => (
|
||||
<TableCell key={`sample1-${colIndex}`} className="px-4 font-mono text-xs">
|
||||
샘플 데이터 1-{colIndex + 1}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-muted/50">
|
||||
{visibleColumns.map((column: any, colIndex: number) => (
|
||||
<TableCell key={`sample2-${colIndex}`} className="px-4 font-mono text-xs">
|
||||
샘플 데이터 2-{colIndex + 1}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-muted/50">
|
||||
{visibleColumns.map((column: any, colIndex: number) => (
|
||||
<TableCell key={`sample3-${colIndex}`} className="px-4 font-mono text-xs">
|
||||
샘플 데이터 3-{colIndex + 1}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 페이지네이션 미리보기 */}
|
||||
{dataTableComponent.pagination?.enabled && (
|
||||
<div className="bg-muted/20 mt-auto border-t">
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
{dataTableComponent.pagination.showPageInfo && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
총 <span className="font-medium">100</span>개 중 <span className="font-medium">1</span>-
|
||||
<span className="font-medium">10</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
{dataTableComponent.pagination.showFirstLast && (
|
||||
<Button size="sm" variant="outline" className="gap-1 text-xs">
|
||||
처음
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="gap-1 text-xs">
|
||||
이전
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
<span>1</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="gap-1 text-xs">
|
||||
다음
|
||||
</Button>
|
||||
{dataTableComponent.pagination.showFirstLast && (
|
||||
<Button size="sm" variant="outline" className="gap-1 text-xs">
|
||||
마지막
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Database className="h-6 w-6" />
|
||||
<p className="text-xs">테이블을 선택하고 컬럼을 설정하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 다른 컴포넌트들은 기존 구조 사용
|
||||
return (
|
||||
<div
|
||||
className={`absolute cursor-move transition-all ${defaultRingClass}`}
|
||||
@@ -755,6 +968,168 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{false &&
|
||||
(() => {
|
||||
const dataTableComponent = component as any; // DataTableComponent 타입
|
||||
const visibleColumns = dataTableComponent.columns?.filter((col: any) => col.visible) || [];
|
||||
const filters = dataTableComponent.filters || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 데이터 테이블 헤더 */}
|
||||
<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">{dataTableComponent.title || label}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
{filters.length > 0 && <div className="text-xs text-gray-500">필터 {filters.length}개</div>}
|
||||
{dataTableComponent.showSearchButton && (
|
||||
<button className="rounded bg-blue-600 px-3 py-1 text-xs text-white">
|
||||
{dataTableComponent.searchButtonText || "검색"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 미리보기 */}
|
||||
{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: filters.map((filter: any) => `${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 (
|
||||
<div key={index} className="text-xs">
|
||||
<div className="mb-1 flex items-center space-x-1 text-gray-600">
|
||||
<span>{getFilterIcon(filter.widgetType)}</span>
|
||||
<span>{filter.label}</span>
|
||||
</div>
|
||||
<div className="rounded border bg-white px-2 py-1 text-gray-400">
|
||||
{getFilterPlaceholder(filter.widgetType)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 내용 미리보기 */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="space-y-2">
|
||||
{/* 테이블 헤더 행 */}
|
||||
{visibleColumns.length > 0 ? (
|
||||
<div
|
||||
className="gap-2 border-b pb-2 text-xs font-medium text-gray-700"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: visibleColumns.map((col: any) => `${col.gridColumns || 2}fr`).join(" "),
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map((column: any) => (
|
||||
<div key={column.id} className="truncate">
|
||||
{column.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-gray-500">테이블을 선택하고 컬럼을 설정하세요</div>
|
||||
)}
|
||||
|
||||
{/* 샘플 데이터 행들 */}
|
||||
{visibleColumns.length > 0 &&
|
||||
[1, 2, 3].map((row) => (
|
||||
<div
|
||||
key={row}
|
||||
className="gap-2 py-1 text-xs text-gray-600"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: visibleColumns
|
||||
.map((col: any) => `${col.gridColumns || 2}fr`)
|
||||
.join(" "),
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map((column: any, colIndex: number) => (
|
||||
<div key={colIndex} className="truncate">
|
||||
샘플 데이터 {row}-{colIndex + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 미리보기 */}
|
||||
{dataTableComponent.pagination?.enabled && (
|
||||
<div className="border-t bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
{dataTableComponent.pagination.showPageInfo && (
|
||||
<div>총 100개 중 1-{dataTableComponent.pagination.pageSize || 10}</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-1">
|
||||
{dataTableComponent.pagination.showFirstLast && (
|
||||
<button className="rounded border px-2 py-1">처음</button>
|
||||
)}
|
||||
<button className="rounded border px-2 py-1">이전</button>
|
||||
<span className="px-2">1</span>
|
||||
<button className="rounded border px-2 py-1">다음</button>
|
||||
{dataTableComponent.pagination.showFirstLast && (
|
||||
<button className="rounded border px-2 py-1">마지막</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === "group" && (
|
||||
<div className="relative h-full w-full">
|
||||
{/* 그룹 내용 */}
|
||||
|
||||
@@ -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
|
||||
<div className="text-center text-gray-400">
|
||||
<Database className="mx-auto mb-4 h-16 w-16" />
|
||||
<h3 className="mb-2 text-xl font-medium">캔버스가 비어있습니다</h3>
|
||||
<p className="text-sm">좌측 테이블 패널에서 테이블이나 컬럼을 드래그하여 화면을 설계하세요</p>
|
||||
<p className="mt-2 text-xs">단축키: T(테이블), P(속성), S(스타일), R(격자), D(상세설정)</p>
|
||||
<p className="text-sm">좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요</p>
|
||||
<p className="mt-2 text-xs">단축키: T(테이블), M(템플릿), P(속성), S(스타일), R(격자), D(상세설정)</p>
|
||||
<p className="mt-1 text-xs">
|
||||
편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제)
|
||||
</p>
|
||||
@@ -2121,6 +2392,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
/>
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
id="templates"
|
||||
title="템플릿"
|
||||
isOpen={panelStates.templates?.isOpen || false}
|
||||
onClose={() => closePanel("templates")}
|
||||
position="left"
|
||||
width={380}
|
||||
height={700}
|
||||
autoHeight={false}
|
||||
>
|
||||
<TemplatesPanel
|
||||
onDragStart={(e, template) => {
|
||||
const dragData = {
|
||||
type: "template",
|
||||
template,
|
||||
};
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
}}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
id="properties"
|
||||
title="속성 편집"
|
||||
@@ -2133,7 +2425,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
>
|
||||
<PropertiesPanel
|
||||
selectedComponent={selectedComponent || undefined}
|
||||
tables={tables}
|
||||
onUpdateProperty={(path: string, value: any) => {
|
||||
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);
|
||||
}
|
||||
|
||||
1138
frontend/components/screen/panels/DataTableConfigPanel.tsx
Normal file
1138
frontend/components/screen/panels/DataTableConfigPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<PropertiesPanelProps> = ({
|
||||
selectedComponent,
|
||||
tables = [],
|
||||
onUpdateProperty,
|
||||
onDeleteComponent,
|
||||
onCopyComponent,
|
||||
@@ -71,6 +74,7 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||
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<PropertiesPanelProps> = ({
|
||||
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<PropertiesPanelProps> = ({
|
||||
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<PropertiesPanelProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 테이블 컴포넌트인 경우 전용 패널 사용
|
||||
if (selectedComponent.type === "datatable") {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5 text-gray-600" />
|
||||
<span className="text-lg font-semibold">데이터 테이블 설정</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedComponent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onCopyComponent}>
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={onDeleteComponent}>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 설정 패널 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<DataTableConfigPanel
|
||||
key={`datatable-${selectedComponent.id}-${selectedComponent.columns.length}-${selectedComponent.filters.length}-${JSON.stringify(selectedComponent.columns.map((c) => 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 컴포넌트 업데이트 완료");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
@@ -210,6 +292,7 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||
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<PropertiesPanelProps> = ({
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="labelDisplay"
|
||||
checked={selectedComponent.style?.labelDisplay !== false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("style.labelDisplay", checked)}
|
||||
checked={localInputs.labelDisplay}
|
||||
onCheckedChange={(checked) => {
|
||||
console.log("🔄 라벨 표시 변경:", checked);
|
||||
setLocalInputs((prev) => ({ ...prev, labelDisplay: checked as boolean }));
|
||||
onUpdateProperty("style.labelDisplay", checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -409,6 +496,7 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||
value={localInputs.labelText}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
console.log("🔄 라벨 텍스트 변경:", newValue);
|
||||
setLocalInputs((prev) => ({ ...prev, labelText: newValue }));
|
||||
// 기본 라벨과 스타일 라벨을 모두 업데이트
|
||||
onUpdateProperty("label", newValue);
|
||||
|
||||
170
frontend/components/screen/panels/TemplatesPanel.tsx
Normal file
170
frontend/components/screen/panels/TemplatesPanel.tsx
Normal file
@@ -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: <Table className="h-4 w-4" />,
|
||||
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<TemplatesPanelProps> = ({ onDragStart }) => {
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const [selectedCategory, setSelectedCategory] = React.useState<string>("all");
|
||||
|
||||
const categories = [
|
||||
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
|
||||
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
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 (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
{/* 검색 */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="템플릿 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategory === category.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{category.icon}
|
||||
<span>{category.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 템플릿 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-center text-gray-500">
|
||||
<div>
|
||||
<FileText className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-sm">검색 결과가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
draggable
|
||||
onDragStart={(e) => 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"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
|
||||
{template.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="truncate font-medium text-gray-900">{template.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{template.components.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{template.description}</p>
|
||||
<div className="mt-2 flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span>
|
||||
{template.defaultSize.width}×{template.defaultSize.height}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">{template.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
|
||||
<div className="text-xs text-blue-700">
|
||||
<p className="mb-1 font-medium">사용 방법</p>
|
||||
<p>템플릿을 캔버스로 드래그하여 빠르게 화면을 구성하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplatesPanel;
|
||||
@@ -145,6 +145,35 @@ export const tableTypeApi = {
|
||||
detailSettings,
|
||||
});
|
||||
},
|
||||
|
||||
// 테이블 데이터 조회 (페이지네이션 + 검색)
|
||||
getTableData: async (
|
||||
tableName: string,
|
||||
params: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: Record<string, any>; // 검색 조건
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
} = {},
|
||||
): Promise<{
|
||||
data: Record<string, any>[];
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user